From 266fcf9c74f6b5b725b00355074039d65c718067 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Tue, 31 Mar 2020 22:37:52 -0400 Subject: [PATCH 01/39] CLI Tools --- src/basilisp/cli_tools.lpy | 206 +++++++++++++++++++++++++++++++++++++ src/basilisp/core.lpy | 31 ++++-- 2 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 src/basilisp/cli_tools.lpy diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy new file mode 100644 index 000000000..67dee0f47 --- /dev/null +++ b/src/basilisp/cli_tools.lpy @@ -0,0 +1,206 @@ +(ns basilisp.cli-tools + (:import + argparse + sys)) + +(defn ^:private argument-parser-kwargs + [config] + {:prog (:command config) + :description (:description config) + :usage (:usage config) + :epilog (:epilogue config) + :add-help (:include-help config true) + :allow-abbrev (:allow-abbrev config true)}) + +(declare ^:private setup-parser) + +(defn ^:private add-cli-sub-parser + "Add a new command parser to the subparsers object." + [subparsers command] + (let [command-parser (->> (argument-parser-kwargs command) + (merge {:title (:title command)}) + (apply-method-kw subparsers add-parser))] + (setup-parser command-parser command) + nil)) + +(defn ^:private cli-tools-add-action + [update-fn] + (fn ^:flatten-kwargs action [option-strings dest & args] + (fn ^:flatten-kwargs do-action [parser namespace values & {:keys [option-string]}] + (if-let [v (python/getattr namespace dest)] + (python/setattr namespace dest (update-fn v)) + (python/setattr ))))) + +(defn ^:private cli-tools-update-action + [update-fn] + (fn ^:flatten-kwargs action [option-strings dest & args] + (fn ^:flatten-kwargs do-action [parser namespace values & {:keys [option-string]}] + (if-let [v (python/getattr namespace dest)] + (python/setattr namespace dest (update-fn v)) + (python/setattr ))))) + +(defn ^:private add-argument + [parser argument] + (let [;; ArgumentParser has a "type" function which can convert the string value + ;; from the CLI into its final value. We decompose that into a parse and + ;; validate step in the argument config and re-compose those steps here. + validate-fn (when-let [[f msg] (:validate argument)] + (fn [v] + (if-not (f v) + (throw + (argparse/ArgumentTypeError msg)) + v))) + type-fn (if-let [parse-fn (:parse-fn argument)] + (cond->> parse-fn + validate-fn (comp validate-fn)) + validate-fn) + + ;; Python's ArgumentParser.add_argument method takes variadic positional + ;; arguments _and_ keyword arguments, which is a challenging combination + ;; to call via Basilisp, so we create a partial of the method with the + ;; kwargs then (apply method args) for the potentially variadic positionals. + method (->> {:default (:default argument) + :action (if-let [update-fn (:update-fn argument)] + (cli-tools-action update-fn) + "store") + :type type-fn + :metavar (when-let [metavar (:metavar argument)] + (cond-> metavar + (vector? metavar) (python/tuple))) + :help (:help argument)} + (partial-kw (.-add-argument parser))) + name (:name argument) + flags (:flags argument)] + (when (and name flags) + (throw + (ex-info (str "Arguments may either be positional (via :name) or " + "optional (via :flags), not both") + {:name name + :flags flags}))) + (if name + (method name) + (apply method flags)) + nil)) + +(defn ^:private setup-parser + "Set up the command parser from the configuration map." + [parser config] + (when-let [commands (:commands config)] + (let [subparsers (.add-subparsers parser)] + (doseq [command commands] + (add-cli-sub-parser subparsers command)))) + ;; Add arguments to the parser. Arguments can be grouped as + (when-let [arguments (:arguments config)] + (let [groups (group-by (fn [v] + (condp #(contains? %2 %1) v + :exclusive-group :ex-group + :group :group + :normal)) + arguments)] + ;; Mutually-exclusive argument groups + (let [ex-groups (group-by :exclusive-group (:ex-group groups))] + (doseq [ex-group-pair ex-groups] + (let [[_ ex-group] ex-group-pair + ex-grouper (.add-mutually-exclusive-group parser)] + (doseq [argument ex-group] + (add-argument ex-grouper argument))))) + ;; Argument groups + (let [display-groups (group-by :group (:group groups))] + (doseq [display-group-pair display-groups] + (let [[title group] display-group-pair + grouper (.add-argument-group parser (name title))] + (doseq [argument group] + (add-argument grouper argument))))) + ;; All other arguments + (doseq [argument (:normal groups)] + (add-argument parser argument)))) + nil) + +;; Config map spec +(comment + {;; Primary CLI entrypoint configuration + + ;; Name the command as it appears in the help text. If nil is provided, + ;; `sys.argv[0]` is used. Default nil. + :command "command" + + ;; The description which appears immediately under the usage text. Optional. + :description "This command foos the bars." + + ;; The usage message emitted when invalid arguments are provided or when + ;; printing the help text. If nil is provided, the usage message will be + ;; calculated automatically by the arguments and commands provided. + :usage "%(prog)s [options]" + + ;; The text which appears beneath the generated help message. Optional. + :epilogue "" + + ;; If true, include the `-h/--help` commands by default. Default true. + :include-help true + + ;; If true, allow abbreviations for unambiguous partial flags. Default true. + :allow-abbrev true + + ;; Arguments which apply globally. Default nil. + :arguments [{;; The name of a positional argument. Mutually exclusive with + ;; the `:flags` key. + :name "foo" + + ;; The flags for an optional argument. Mutually exclusive with + ;; the `:name` key. + :flags ["-f" "--foo"] + + ;; A function of one string argument which returns the intended + ;; final value. Default nil. + :parse-fn int + + ;; A vector of two elements. The first element is a function + ;; which can accept the value from `:parse-fn` (if given) or the + ;; string value from the CLI (if `:parse-fn` is not given). If + ;; the function returns a falsey value, the value will be + ;; rejected. Default nil. + :validate [pos? "foo must be positive"] + + ;; A default value which will be supplied if the flag is not + ;; given. If the value is a string, it will be subject to the + ;; `:parse-fn` and `:validate` rules, if given. Non-string + ;; values will be used as-is. Default nil. + :default "yes" + + ;; Either a string value or vector of strings used to refer to + ;; expected arguments generated help text message. + :metavar "FOO" + + ;; A help string which will be shown next to the argument in the + ;; CLI help text. Default nil. + :help "Add extra foos into the bars."}] + + ;; Subcommands which will be added to the parser. Default nil. + :commands []}) + +(defn cli-parser + "Create a CLI argument parser from the configuration map. + + Configuration maps have the following spec" + [config] + (let [parser (->> (argument-parser-kwargs config) + (apply-kw argparse/ArgumentParser))] + (setup-parser parser config) + parser)) + +(defn parse-args + "Parse command line arguments using the supplied parser or CLI tools configuration + map. If no `args` are given, parse the arguments from Python's `sys/argv`." + ([parser-or-config] + (parse-args parser-or-config sys/argv)) + ([parser-or-config args] + (let [parser (cond-> parser-or-config + (map? parser-or-config) (cli-parser))] + (-> (.parse-args parser args) + (python/vars) + (py->lisp))))) + +(defmacro run-cli + [config] + `(when (= --name-- "__main__") + (parse-args ~config))) diff --git a/src/basilisp/core.lpy b/src/basilisp/core.lpy index 5854eda3f..3d340b4fa 100644 --- a/src/basilisp/core.lpy +++ b/src/basilisp/core.lpy @@ -2759,6 +2759,11 @@ [o method & args] `(apply (. ~o ~(symbol (str "-" method))) ~@args)) +(defmacro apply-method-kw + "Apply keyword arguments to a method call. Equivalent to (apply-kw (.-method o) ... args)." + [o method & args] + `(apply-kw (. ~o ~(symbol (str "-" method))) ~@args)) + (defmacro comment "Ignore all the forms passed, returning nil." [& ^:no-warn-when-unused forms] @@ -4467,9 +4472,22 @@ (defn gen-interface "Generate and return a new Python interface (abstract base clase). + Options may be specified as key-value pairs. The following options are supported: + - :name - the name of the interface as a string; required + - :extends - a vector of interfaces the new interface should extend; optional + - :methods - an optional vector of method signatures like: + [ (method-name [args ...] docstring) ... ] + Callers should use `definterface` to generate new interfaces." - [interface-name methods] - (let [methods (reduce (fn [m [method-name args docstring]] + [& opts] + (let [opt-map (apply hash-map opts) + interface-name (:name opt-map) + extends (as-> (:extends opt-map []) $ + (remove #(identical? abc/ABC %) $) + (concat $ [abc/ABC]) + (python/tuple $)) + + methods (reduce (fn [m [method-name args docstring]] (let [method-args (->> (concat ['^:no-warn-when-unused self] args) (apply vector)) method (->> (list 'fn* method-name method-args) @@ -4479,10 +4497,10 @@ (set! (.- method __doc__) docstring)) (assoc m (munge method-name) method))) {} - methods)] + (:methods opt-map))] (python/type interface-name - #py (abc/ABC) - (lisp->py methods)))) + extends + (lisp->py methods)))) ;; The behavior described below where `definterface` forms are not permitted ;; to be used in `deftype` and `defrecord` forms when they are not top-level @@ -4513,7 +4531,8 @@ (let [name-str (name interface-name) method-sigs (map #(list 'quote %) methods)] `(def ~interface-name - (gen-interface ~name-str [~@method-sigs])))) + (gen-interface :name ~name-str + :methods [~@method-sigs])))) ;;;;;;;;;;;;;;;; ;; Data Types ;; From 3abb68c816b75f79a06fe54e4ad2e4e9a5b85670 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sat, 13 Jun 2020 13:37:20 -0400 Subject: [PATCH 02/39] Changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afda763f8..f06aa0b1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,9 @@ 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) + * Added JSON encoder and decoder in `basilisp.json` namespace (#484) + * Added support for generically diffing Basilisp data structures in `basilisp.data` namespace (#555) + * Added CLI argument parser in `basilisp.cli-tools` namespace (#535) ### Changed * Basilisp set and map types are now backed by the HAMT provided by `immutables` (#557) From 21374d869272be8eb7b5d9edf8142faea6fa4a59 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sat, 13 Jun 2020 13:56:41 -0400 Subject: [PATCH 03/39] Small things --- src/basilisp/cli_tools.lpy | 2 ++ tests/basilisp/test_cli_tools.lpy | 4 ++++ 2 files changed, 6 insertions(+) create mode 100644 tests/basilisp/test_cli_tools.lpy diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 67dee0f47..303a465a3 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -201,6 +201,8 @@ (py->lisp))))) (defmacro run-cli + "Create a new command-line parser using the configuration `config` and run it + automatically if this namespace is called as the Python `__main__`." [config] `(when (= --name-- "__main__") (parse-args ~config))) diff --git a/tests/basilisp/test_cli_tools.lpy b/tests/basilisp/test_cli_tools.lpy new file mode 100644 index 000000000..958161e72 --- /dev/null +++ b/tests/basilisp/test_cli_tools.lpy @@ -0,0 +1,4 @@ +(ns tests.basilisp.cli-tools-test + (:require + [basilisp.cli-tools :as cli] + [basilisp.test :refer [deftest are is testing]])) From cb1f20d66f5f3cbaf5ea52772512b307a7b2b793 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Mon, 15 Jun 2020 22:06:27 -0400 Subject: [PATCH 04/39] At least compile now --- src/basilisp/cli_tools.lpy | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 303a465a3..3077dfb69 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -25,19 +25,23 @@ (defn ^:private cli-tools-add-action [update-fn] - (fn ^:flatten-kwargs action [option-strings dest & args] - (fn ^:flatten-kwargs do-action [parser namespace values & {:keys [option-string]}] - (if-let [v (python/getattr namespace dest)] - (python/setattr namespace dest (update-fn v)) - (python/setattr ))))) + (fn ^{:kwargs :collect} AddAction [_ dest _] + (reify + ^:abstract argparse/Action + (^{:kwargs :collect} __call__ [self _ namespace values _] + (if-let [v (python/getattr namespace dest)] + (python/setattr namespace dest (update-fn v)) + (python/setattr )))))) (defn ^:private cli-tools-update-action [update-fn] - (fn ^:flatten-kwargs action [option-strings dest & args] - (fn ^:flatten-kwargs do-action [parser namespace values & {:keys [option-string]}] - (if-let [v (python/getattr namespace dest)] - (python/setattr namespace dest (update-fn v)) - (python/setattr ))))) + (fn ^{:kwargs :collect} UpdateAction [_ dest _] + (reify + ^:abstract argparse/Action + (^{:kwargs :collect} __call__ [self _ namespace values _] + (if-let [v (python/getattr namespace dest)] + (python/setattr namespace dest (update-fn v)) + (python/setattr )))))) (defn ^:private add-argument [parser argument] @@ -61,7 +65,7 @@ ;; kwargs then (apply method args) for the potentially variadic positionals. method (->> {:default (:default argument) :action (if-let [update-fn (:update-fn argument)] - (cli-tools-action update-fn) + (cli-tools-update-action update-fn) "store") :type type-fn :metavar (when-let [metavar (:metavar argument)] From f9ebbd5d6547cdf3ed308b9994eba7e8b37b0eed Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Mon, 15 Jun 2020 22:22:16 -0400 Subject: [PATCH 05/39] More things --- src/basilisp/cli_tools.lpy | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 3077dfb69..4f58d0f99 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -24,16 +24,18 @@ nil)) (defn ^:private cli-tools-add-action - [update-fn] + "Create a new CLI Tools Add Action." + [assoc-fn] (fn ^{:kwargs :collect} AddAction [_ dest _] (reify ^:abstract argparse/Action (^{:kwargs :collect} __call__ [self _ namespace values _] (if-let [v (python/getattr namespace dest)] - (python/setattr namespace dest (update-fn v)) + (python/setattr namespace dest (assoc-fn v)) (python/setattr )))))) (defn ^:private cli-tools-update-action + "Create a new CLI Tools Update Action." [update-fn] (fn ^{:kwargs :collect} UpdateAction [_ dest _] (reify @@ -43,6 +45,18 @@ (python/setattr namespace dest (update-fn v)) (python/setattr )))))) +(defn ^:private cli-tools-action + [{:keys [assoc-fn update-fn]}] + (when (and assoc-fn update-fn) + (throw + (ex-info (str "Arguments may only specify either an :assoc-fn or " + "an :update-fn, not both") + {:assoc-fn assoc-fn + :update-fn update-fn}))) + (if update-fn + (cli-tools-update-action update-fn) + (cli-tools-add-action (or assoc-fn assoc)))) + (defn ^:private add-argument [parser argument] (let [;; ArgumentParser has a "type" function which can convert the string value @@ -63,15 +77,13 @@ ;; arguments _and_ keyword arguments, which is a challenging combination ;; to call via Basilisp, so we create a partial of the method with the ;; kwargs then (apply method args) for the potentially variadic positionals. - method (->> {:default (:default argument) - :action (if-let [update-fn (:update-fn argument)] - (cli-tools-update-action update-fn) - "store") - :type type-fn - :metavar (when-let [metavar (:metavar argument)] - (cond-> metavar - (vector? metavar) (python/tuple))) - :help (:help argument)} + method (->> {:default (:default argument) + :action (cli-tools-action argument) + :type type-fn + :metavar (when-let [metavar (:metavar argument)] + (cond-> metavar + (vector? metavar) (python/tuple))) + :help (:help argument)} (partial-kw (.-add-argument parser))) name (:name argument) flags (:flags argument)] From 3b4fa915adfe0e50ebd718b2d7577c8975709842 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Tue, 16 Jun 2020 09:21:19 -0400 Subject: [PATCH 06/39] More changes --- src/basilisp/cli_tools.lpy | 33 ++++++++++++++++++++----- src/basilisp/lang/compiler/generator.py | 2 +- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 4f58d0f99..b4db49ee0 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -14,6 +14,29 @@ (declare ^:private setup-parser) +(defn ^:private args-namespace + "Create a new `argparse.Namespace` object for the argument parser. + + `argparse` uses Namespace objects to collect arguments parsed from the command + line. These objects are just bags of attributes, so it needs to be able to + call `getattr`, `delattr`, and `setattr`. We store the attributes in a map in + a volatile and proxy the gets, deletes, and sets to the map. + + Return the completed map using `deref` on the returned object." + [] + (let [m (volatile! {})] + (reify + ^:abstract argparse/Namespace + (__getattr__ [self name] + (get @m name)) + (__delattr__ [self name] + (vswap! m dissoc name)) + (__setattr__ [self name value] + (vswap! m assoc name value)) + basilisp.lang.interfaces/IDeref + (deref [self] + @m)))) + (defn ^:private add-cli-sub-parser "Add a new command parser to the subparsers object." [subparsers command] @@ -41,9 +64,9 @@ (reify ^:abstract argparse/Action (^{:kwargs :collect} __call__ [self _ namespace values _] - (if-let [v (python/getattr namespace dest)] - (python/setattr namespace dest (update-fn v)) - (python/setattr )))))) + (->> (python/getattr namespace dest nil) + (update-fn) + (python/setattr namespace dest)))))) (defn ^:private cli-tools-action [{:keys [assoc-fn update-fn]}] @@ -212,9 +235,7 @@ ([parser-or-config args] (let [parser (cond-> parser-or-config (map? parser-or-config) (cli-parser))] - (-> (.parse-args parser args) - (python/vars) - (py->lisp))))) + @(.parse-args parser args ** :namespace (namespace))))) (defmacro run-cli "Create a new command-line parser using the configuration `config` and run it diff --git a/src/basilisp/lang/compiler/generator.py b/src/basilisp/lang/compiler/generator.py index 08efab15e..ee31fbe68 100644 --- a/src/basilisp/lang/compiler/generator.py +++ b/src/basilisp/lang/compiler/generator.py @@ -2470,7 +2470,7 @@ def _reify_to_py_ast( ), verified_abstract=node.verified_abstract, artificially_abstract_bases=artificially_abstract_bases, - is_frozen=True, + is_frozen=False, use_slots=_ATTR_SLOTS_ON, ) ], From da1d83985430feacde680cb05794ea6489ee1eaa Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Mon, 22 Jun 2020 08:51:28 -0400 Subject: [PATCH 07/39] More things and also stuff --- src/basilisp/cli_tools.lpy | 48 ++++++++++++++++++++++++++++---------- src/basilisp/core.lpy | 17 ++++++++++++++ 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index b4db49ee0..50e0ade60 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -22,6 +22,9 @@ call `getattr`, `delattr`, and `setattr`. We store the attributes in a map in a volatile and proxy the gets, deletes, and sets to the map. + The returned Namespace object also implements the `IVolatile` interface to + trivially support `assoc`-like functionality on the map of options. + Return the completed map using `deref` on the returned object." [] (let [m (volatile! {})] @@ -33,6 +36,13 @@ (vswap! m dissoc name)) (__setattr__ [self name value] (vswap! m assoc name value)) + + IVolatile + (vreset! [this new-val] + (vreset! m new-val)) + (vswap! [this f & args] + (apply vswap! m f args)) + basilisp.lang.interfaces/IDeref (deref [self] @m)))) @@ -46,29 +56,43 @@ (setup-parser command-parser command) nil)) -(defn ^:private cli-tools-add-action - "Create a new CLI Tools Add Action." +(defn ^:private cli-tools-assoc-action + "Create a new CLI Tools Assoc Action. + + Add actions allow users to define actions with `assoc`-like semantics on the + parsed options map. + + The provided `assoc-fn` will be passed the entire options map, the relevant + destination key, and the parsed option and should return a new options map." [assoc-fn] - (fn ^{:kwargs :collect} AddAction [_ dest _] + (fn ^{:kwargs :collect} AssocAction [_ dest _] (reify ^:abstract argparse/Action (^{:kwargs :collect} __call__ [self _ namespace values _] - (if-let [v (python/getattr namespace dest)] - (python/setattr namespace dest (assoc-fn v)) - (python/setattr )))))) + (vswap! assoc-fn namespace dest values))))) (defn ^:private cli-tools-update-action - "Create a new CLI Tools Update Action." + "Create a new CLI Tools Update Action. + + Update actions allow users to define actions which update the current value + stored in the options map. + + The provided `update-fn` will be passed the current value of the given option + (or `nil` if no default is provided) and should return a new value for the + option." [update-fn] (fn ^{:kwargs :collect} UpdateAction [_ dest _] (reify ^:abstract argparse/Action - (^{:kwargs :collect} __call__ [self _ namespace values _] - (->> (python/getattr namespace dest nil) - (update-fn) - (python/setattr namespace dest)))))) + (^{:kwargs :collect} __call__ [self _ namespace _ _] + (vswap! namespace update dest update-fn))))) (defn ^:private cli-tools-action + "Return a new Action for the argument definition. + + Users may provide at most one of `:assoc-fn` or `:update-fn`. If both are given, + an exception will be thrown. If neither is provided, a default assoc action will + be returned using `assoc` as the function." [{:keys [assoc-fn update-fn]}] (when (and assoc-fn update-fn) (throw @@ -78,7 +102,7 @@ :update-fn update-fn}))) (if update-fn (cli-tools-update-action update-fn) - (cli-tools-add-action (or assoc-fn assoc)))) + (cli-tools-assoc-action (or assoc-fn assoc)))) (defn ^:private add-argument [parser argument] diff --git a/src/basilisp/core.lpy b/src/basilisp/core.lpy index 5bee6a32a..f3113f7f2 100644 --- a/src/basilisp/core.lpy +++ b/src/basilisp/core.lpy @@ -2335,6 +2335,23 @@ [] args))) +(defn fnil + "Given a function `f`, return a new function which replaces a `nil` first argument + with the value `x`. Higher arity variants will replace their corresponding `nil` + argument with the provided default value for that argument position. + + The function returned from `fnil` supports any number of arguments greater than or + equal to the arity of the `fnil` that is called." + ([f x] + (fn [a & args] + (apply f (or a x) args))) + ([f x y] + (fn [a b & args] + (apply f (or a x) (or b y) args))) + ([f x y z] + (fn [a b c & args] + (apply f (or a x) (or b y) (or c z) args)))) + (defn partial "Return a function which is the partial application of f with args." ([f] f) From 87adf64cf78f40dfb1c4ef7f8fb70f0c51d5042d Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Mon, 22 Jun 2020 09:11:33 -0400 Subject: [PATCH 08/39] Who even knows --- src/basilisp/cli_tools.lpy | 15 ++++++++++++ tests/basilisp/test_cli_tools.lpy | 38 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 50e0ade60..e23fc55c2 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -230,6 +230,21 @@ ;; values will be used as-is. Default nil. :default "yes" + ;; An `assoc`-like function which will be passed the current + ;; options map, the relevant destination key, and the parsed + ;; option and should return a new options map. + ;; + ;; If neither `:assoc-fn`, nor `:update-fn` are provided, then + ;; a default of `assoc` will be given for `:assoc-fn`. If + ;; `:update-fn` is provided, it will be used. If both keys are + ;; given, an exception will be thrown. + :assoc-fn assoc + + ;; A function of one argument which will receive the current + ;; value of the associated option and must return an updated + ;; value. Default nil. + :update-fn (fnil inc 0) + ;; Either a string value or vector of strings used to refer to ;; expected arguments generated help text message. :metavar "FOO" diff --git a/tests/basilisp/test_cli_tools.lpy b/tests/basilisp/test_cli_tools.lpy index 958161e72..5a51d5c13 100644 --- a/tests/basilisp/test_cli_tools.lpy +++ b/tests/basilisp/test_cli_tools.lpy @@ -2,3 +2,41 @@ (:require [basilisp.cli-tools :as cli] [basilisp.test :refer [deftest are is testing]])) + +(def ^:private base-parser-config + {:command "command" + :description "This command foos the bars." + :allow-abbrev true + :arguments [] + :commands []}) + +(def ^:private base-arg-config + {:name "foo" + :parse-fn int + :validate [pos? "foo must be positive"] + :default "yes" + :metavar "FOO" + :help "Add extra foos into the bars."}) + +(def ^:private base-flag-config + {:flags ["-f" "--foo"] + :parse-fn int + :validate [pos? "foo must be positive"] + :default "yes" + :metavar "FOO" + :help "Add extra foos into the bars."}) + +(deftest cli-parser-spec-validation + (testing "disallow name and flags" + (is (thrown? basilisp.lang.exception/ExceptionInfo + (-> base-arg-config + (assoc :flags ["-f" "--foo"]) + (->> (update base-parser-config conj :arguments)) + (cli/cli-parser))))) + + (testing "disallow assoc-fn and update-fn" + (is (thrown? basilisp.lang.exception/ExceptionInfo + (-> base-arg-config + (assoc :assoc-fn assoc :update-fn (fnil inc 0)) + (->> (update base-parser-config conj :arguments)) + (cli/cli-parser)))))) From 5db99b8c4f152120e50a8c4ccc8a0fa74de10588 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Mon, 22 Jun 2020 09:15:21 -0400 Subject: [PATCH 09/39] I am a fool --- tests/basilisp/test_cli_tools.lpy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/basilisp/test_cli_tools.lpy b/tests/basilisp/test_cli_tools.lpy index 5a51d5c13..6d3d91d5d 100644 --- a/tests/basilisp/test_cli_tools.lpy +++ b/tests/basilisp/test_cli_tools.lpy @@ -1,4 +1,4 @@ -(ns tests.basilisp.cli-tools-test +(ns tests.basilisp.test-cli-tools (:require [basilisp.cli-tools :as cli] [basilisp.test :refer [deftest are is testing]])) From c0307dcbd0768c3097a603161961e694123dac16 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Mon, 22 Jun 2020 09:26:52 -0400 Subject: [PATCH 10/39] Test fixes --- tests/basilisp/test_cli_tools.lpy | 4 ++-- tests/basilisp/test_core_fns.lpy | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/basilisp/test_cli_tools.lpy b/tests/basilisp/test_cli_tools.lpy index 6d3d91d5d..06be7822e 100644 --- a/tests/basilisp/test_cli_tools.lpy +++ b/tests/basilisp/test_cli_tools.lpy @@ -31,12 +31,12 @@ (is (thrown? basilisp.lang.exception/ExceptionInfo (-> base-arg-config (assoc :flags ["-f" "--foo"]) - (->> (update base-parser-config conj :arguments)) + (->> (update base-parser-config :arguments conj)) (cli/cli-parser))))) (testing "disallow assoc-fn and update-fn" (is (thrown? basilisp.lang.exception/ExceptionInfo (-> base-arg-config (assoc :assoc-fn assoc :update-fn (fnil inc 0)) - (->> (update base-parser-config conj :arguments)) + (->> (update base-parser-config :arguments conj)) (cli/cli-parser)))))) diff --git a/tests/basilisp/test_core_fns.lpy b/tests/basilisp/test_core_fns.lpy index 2db7913f0..2f9f664bb 100644 --- a/tests/basilisp/test_core_fns.lpy +++ b/tests/basilisp/test_core_fns.lpy @@ -400,6 +400,34 @@ (is (= {1 :a, 2 :b, 3 :c} (reduce-kv #(assoc %1 %3 %2) {} {:a 1 :b 2 :c 3}))))) +(deftest fnil-test + (let [f (fnil (fn [x] x) :yes)] + (is (= :yes (f nil))) + (is (= :no (f :no)))) + + (let [f (fnil (fn [x y] [x y]) :yes :no)] + (is (= [:yes :yes] (f nil :yes))) + (is (= [:no :no] (f :no nil))) + (is (= [:yes :no] (f nil nil)))) + + (let [f (fnil (fn [x y z] [x y z]) :yes :no :maybe)] + (is (= [:yes :yes :yes] (f nil :yes :yes))) + (is (= [:yes :no :yes] (f nil nil :yes))) + (is (= [:no :no :no] (f :no nil :no))) + (is (= [:no :no :maybe] (f :no nil nil))) + (is (= [:maybe :maybe :maybe] (f :maybe :maybe nil))) + (is (= [:yes :maybe :maybe] (f nil :maybe nil))) + (is (= [:yes :no :maybe] (f nil nil nil)))) + + (let [f (fnil (fn [x & rest] [x rest]) :a)] + (is (= [:a '(:b :c)] (f nil :b :c)))) + + (let [f (fnil (fn [x y & rest] [x y rest]) :a :b)] + (is (= [:a :b '(:c :d)] (f nil nil :c :d)))) + + (let [f (fnil (fn [x y z & rest] [x y z rest]) :a :b :c)] + (is (= [:a :b :c '(:d :e)] (f nil nil nil :d :e))))) + (deftest every-pred-test (is (= true ((every-pred odd?) 3 5 9))) (is (= true ((every-pred odd? int?) 3 5 9 17))) From 6a4930116b0b40517c72ed0bd8b02a589e8fe01d Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Thu, 25 Jun 2020 09:52:17 -0400 Subject: [PATCH 11/39] Don't drop Lisp tests --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2bb9dcc48..815090c06 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -149,7 +149,7 @@ jobs: - run: name: Run Tests command: | - CCI_NODE_TESTS=$(circleci tests glob "tests/**/*_test.py" "tests/**/test_*.py" | circleci tests split --split-by=timings) + CCI_NODE_TESTS=$(circleci tests glob "tests/**/*_test.*py" "tests/**/test_*.*py" | circleci tests split --split-by=timings) printf "Test files:\n" echo "$CCI_NODE_TESTS" printf "\n" From 2c6e8a865b6f3c82e7510dde186ce384165ae93c Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Thu, 25 Jun 2020 18:02:10 -0400 Subject: [PATCH 12/39] Remove six limit --- tox.ini | 2 -- 1 file changed, 2 deletions(-) diff --git a/tox.ini b/tox.ini index 0628317eb..813d9f325 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,6 @@ setenv = deps = coverage pygments - six==1.10.0 commands = coverage run \ --source={envsitepackagesdir}/basilisp \ @@ -22,7 +21,6 @@ depends = py36, py37, py38 deps = coveralls coverage - six==1.10.0 passenv = COVERALLS_REPO_TOKEN usedevelop = true From c65e6dfecf6dc86d5de5ed714d314872b8203cfe Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Thu, 25 Jun 2020 18:12:18 -0400 Subject: [PATCH 13/39] Maybe this will give timings --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 813d9f325..011cff70a 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ commands = --source={envsitepackagesdir}/basilisp \ --parallel-mode \ -m pytest \ - --junitxml={toxinidir}/junit/pytest/{envname}.xml \ + --junitxml={toxinidir}/junit/{envname}.xml \ {posargs} [testenv:coverage] From cbcbfcfe3cacf00b463ab81df617d7ec76fe5374 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Thu, 25 Jun 2020 18:27:45 -0400 Subject: [PATCH 14/39] Ok so its the legacy --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 011cff70a..f5618c2d6 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ commands = --source={envsitepackagesdir}/basilisp \ --parallel-mode \ -m pytest \ - --junitxml={toxinidir}/junit/{envname}.xml \ + --junitxml={toxinidir}/junit/pytest/{envname}.xml \ {posargs} [testenv:coverage] @@ -52,7 +52,7 @@ exclude_lines = if __name__ == .__main__.: [pytest] -junit_family = xunit2 +junit_family = legacy [testenv:format] deps = From 1c0533187f2e731ef46af142e218571bac89919c Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sun, 28 Jun 2020 14:16:03 -0400 Subject: [PATCH 15/39] Fix test names --- tests/basilisp/test_edn.lpy | 2 +- tests/basilisp/test_walk.lpy | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/basilisp/test_edn.lpy b/tests/basilisp/test_edn.lpy index 12ee392b5..0ec042a19 100644 --- a/tests/basilisp/test_edn.lpy +++ b/tests/basilisp/test_edn.lpy @@ -1,4 +1,4 @@ -(ns basilisp.test-edn +(ns tests.basilisp.test-edn (:require [basilisp.edn :as edn] [basilisp.test :refer [deftest are is testing]])) diff --git a/tests/basilisp/test_walk.lpy b/tests/basilisp/test_walk.lpy index 41fafc9e6..9bb7dfa48 100644 --- a/tests/basilisp/test_walk.lpy +++ b/tests/basilisp/test_walk.lpy @@ -1,4 +1,4 @@ -(ns tests.basilisp.walk-test +(ns tests.basilisp.test-walk (:require [basilisp.test :refer [deftest is testing]] [basilisp.walk :as walk])) From b2b2de2000c9aae881b92dbc30e1b45d1dd09265 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sun, 28 Jun 2020 14:34:20 -0400 Subject: [PATCH 16/39] Fix bad test badness --- tests/basilisp/test_walk.lpy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/basilisp/test_walk.lpy b/tests/basilisp/test_walk.lpy index 9bb7dfa48..b9a42761a 100644 --- a/tests/basilisp/test_walk.lpy +++ b/tests/basilisp/test_walk.lpy @@ -104,9 +104,9 @@ `(pl ~c1 (minus ~c1 ~c2))) (deftest macroexpand-all-test - (is (= '(tests.basilisp.walk-test/pl 20 (tests.basilisp.walk-test/minus 20 30)) + (is (= '(tests.basilisp.test-walk/pl 20 (tests.basilisp.test-walk/minus 20 30)) (macroexpand-1 '(calc 20 30)))) - (is (= '(basilisp.core/+ 20 (tests.basilisp.walk-test/minus 20 30)) + (is (= '(basilisp.core/+ 20 (tests.basilisp.test-walk/minus 20 30)) (macroexpand '(calc 20 30)))) (is (= '(basilisp.core/+ 20 (basilisp.core/- 20 30)) (walk/macroexpand-all '(calc 20 30))))) From d95614188af73ee5d65cf3699ea1aaf2ae0a7ddf Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sun, 28 Jun 2020 15:02:30 -0400 Subject: [PATCH 17/39] Fix bad walk impl for reader --- src/basilisp/lang/reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/basilisp/lang/reader.py b/src/basilisp/lang/reader.py index def082d1e..3736cf07c 100644 --- a/src/basilisp/lang/reader.py +++ b/src/basilisp/lang/reader.py @@ -951,7 +951,7 @@ def _walk(inner_f, outer_f, form): elif isinstance(form, IPersistentVector): return outer_f(vector.vector(map(inner_f, form))) elif isinstance(form, IPersistentMap): - return outer_f(lmap.from_entries(map(inner_f, form))) + return outer_f(lmap.hash_map(*chain.from_iterable(map(inner_f, form.seq())))) elif isinstance(form, IPersistentSet): return outer_f(lset.set(map(inner_f, form))) else: From 4cdcc86664cc0e6d35f2e3e324e56c3265b23ddb Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sun, 28 Jun 2020 15:03:39 -0400 Subject: [PATCH 18/39] Support argument groups and exclusive groups --- src/basilisp/cli_tools.lpy | 34 ++++++++++++++++++++++++------- tests/basilisp/test_cli_tools.lpy | 7 +++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index e23fc55c2..ee60290c8 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -152,14 +152,22 @@ (let [subparsers (.add-subparsers parser)] (doseq [command commands] (add-cli-sub-parser subparsers command)))) - ;; Add arguments to the parser. Arguments can be grouped as + ;; Add arguments to the parser. Arguments can be grouped as exclusive groups + ;; or as display groups (without mutual exclusion). (when-let [arguments (:arguments config)] - (let [groups (group-by (fn [v] - (condp #(contains? %2 %1) v - :exclusive-group :ex-group - :group :group - :normal)) - arguments)] + (let [groups (->> arguments + (map #(if (and (contains? % :exclusive-group) + (contains? % :group)) + (throw + (ex-info (str "Arguments may be in either a :group " + "or an :exlusive-group, not both.") + {:arg %})) + %)) + (group-by (fn [v] + (condp #(contains? %2 %1) v + :exclusive-group :ex-group + :group :group + :normal))))] ;; Mutually-exclusive argument groups (let [ex-groups (group-by :exclusive-group (:ex-group groups))] (doseq [ex-group-pair ex-groups] @@ -249,6 +257,18 @@ ;; expected arguments generated help text message. :metavar "FOO" + ;; Arguments may be specified to be part of an exclusive group, + ;; in which case only one argument of the group will be permitted. + ;; Arguments may be in either an exclusive group or a display + ;; group, but not both. Default nil. + :exclusive-group :baz + + ;; Arguments may be grouped into display groups which do not enforce + ;; mutual exclusion. These groups will only be used for help text. + ;; Arguments may be in either an exclusive group or a display + ;; group, but not both. Default nil. + :group :bar + ;; A help string which will be shown next to the argument in the ;; CLI help text. Default nil. :help "Add extra foos into the bars."}] diff --git a/tests/basilisp/test_cli_tools.lpy b/tests/basilisp/test_cli_tools.lpy index 06be7822e..eadf06753 100644 --- a/tests/basilisp/test_cli_tools.lpy +++ b/tests/basilisp/test_cli_tools.lpy @@ -39,4 +39,11 @@ (-> base-arg-config (assoc :assoc-fn assoc :update-fn (fnil inc 0)) (->> (update base-parser-config :arguments conj)) + (cli/cli-parser))))) + + (testing "disallow exclusive-group and group" + (is (thrown? basilisp.lang.exception/ExceptionInfo + (-> base-arg-config + (assoc :group :foos :exclusive-group :bars) + (->> (update base-parser-config :arguments conj)) (cli/cli-parser)))))) From 9ebc676b3e2f2b6b6b7259c45bcf7a3ef8173700 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sun, 28 Jun 2020 17:45:20 -0400 Subject: [PATCH 19/39] Manys of fixes --- src/basilisp/cli_tools.lpy | 39 +++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index ee60290c8..3a4506b49 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -51,11 +51,14 @@ "Add a new command parser to the subparsers object." [subparsers command] (let [command-parser (->> (argument-parser-kwargs command) - (merge {:title (:title command)}) - (apply-method-kw subparsers add-parser))] + (apply-method-kw subparsers add-parser (:command command)))] (setup-parser command-parser command) nil)) +;; Deep inside of argparse, they create actions using the `action_class(**kwargs)` +;; syntax, so collect all arguments into keyword arguments is the safest option +;; for faking an Action class. + (defn ^:private cli-tools-assoc-action "Create a new CLI Tools Assoc Action. @@ -65,11 +68,16 @@ The provided `assoc-fn` will be passed the entire options map, the relevant destination key, and the parsed option and should return a new options map." [assoc-fn] - (fn ^{:kwargs :collect} AssocAction [_ dest _] - (reify - ^:abstract argparse/Action - (^{:kwargs :collect} __call__ [self _ namespace values _] - (vswap! assoc-fn namespace dest values))))) + (fn ^{:kwargs :collect} AssocAction [{:keys [dest] :as kwargs}] + (let [kwargs (->> (get kwargs :option-strings []) + (assoc kwargs :option-strings))] + (reify + ^:abstract argparse/Action + (^{:kwargs :collect} __call__ [self _ namespace values _ _] + (println (type namespace) (.mro (type namespace))) + (vswap! namespace assoc-fn dest values)) + (__getattr__ [self name] + (get kwargs (keyword (basilisp.lang.util/demunge name)))))))) (defn ^:private cli-tools-update-action "Create a new CLI Tools Update Action. @@ -81,11 +89,16 @@ (or `nil` if no default is provided) and should return a new value for the option." [update-fn] - (fn ^{:kwargs :collect} UpdateAction [_ dest _] - (reify - ^:abstract argparse/Action - (^{:kwargs :collect} __call__ [self _ namespace _ _] - (vswap! namespace update dest update-fn))))) + (fn ^{:kwargs :collect} UpdateAction [{:keys [dest] :as kwargs}] + (let [kwargs (->> (get kwargs :option-strings []) + (assoc kwargs :option-strings))] + (reify + ^:abstract argparse/Action + (^{:kwargs :collect} __call__ [self _ namespace _ _ _] + (println (type namespace)) + (vswap! namespace update dest update-fn)) + (__getattr__ [self name] + (get kwargs (keyword (basilisp.lang.util/demunge name)))))))) (defn ^:private cli-tools-action "Return a new Action for the argument definition. @@ -294,7 +307,7 @@ ([parser-or-config args] (let [parser (cond-> parser-or-config (map? parser-or-config) (cli-parser))] - @(.parse-args parser args ** :namespace (namespace))))) + @(.parse-args parser args ** :namespace (args-namespace))))) (defmacro run-cli "Create a new command-line parser using the configuration `config` and run it From 3d6144c294c2f7003fb698a9106f43f0047ab798 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sun, 28 Jun 2020 18:05:09 -0400 Subject: [PATCH 20/39] Ok --- src/basilisp/cli_tools.lpy | 2 -- tests/basilisp/test_cli_tools.lpy | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 3a4506b49..3e00deb98 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -74,7 +74,6 @@ (reify ^:abstract argparse/Action (^{:kwargs :collect} __call__ [self _ namespace values _ _] - (println (type namespace) (.mro (type namespace))) (vswap! namespace assoc-fn dest values)) (__getattr__ [self name] (get kwargs (keyword (basilisp.lang.util/demunge name)))))))) @@ -95,7 +94,6 @@ (reify ^:abstract argparse/Action (^{:kwargs :collect} __call__ [self _ namespace _ _ _] - (println (type namespace)) (vswap! namespace update dest update-fn)) (__getattr__ [self name] (get kwargs (keyword (basilisp.lang.util/demunge name)))))))) diff --git a/tests/basilisp/test_cli_tools.lpy b/tests/basilisp/test_cli_tools.lpy index eadf06753..22a445c46 100644 --- a/tests/basilisp/test_cli_tools.lpy +++ b/tests/basilisp/test_cli_tools.lpy @@ -47,3 +47,21 @@ (assoc :group :foos :exclusive-group :bars) (->> (update base-parser-config :arguments conj)) (cli/cli-parser)))))) + +(def parser + (cli/cli-parser + {:command "basilisp" + :description "Basilisp is a Lisp dialect inspired by Clojure targeting Python 3" + :arguments [] + :commands [{:command "run" + :description "run a Basilisp script or code" + :arguments [{:name "file-or-code"} + {:flags ["-c" "--code"] + :help "if provided, treat argument as a string of code"} + + {:flags ["--warn-on-shadowed-name"] + :help "if provided, emit warnings if a local name is shadowed by another local name" + :group :compiler-flags} + {:flags ["--warn-on-shadowed-var"] + :help "if provided, emit warnings if a Var name is shadowed by a local name" + :group :compiler-flags}]}]})) From d193d3751ebb385f3fc847d2121a0e2390371cb9 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sun, 28 Jun 2020 21:46:54 -0400 Subject: [PATCH 21/39] Does this work? --- src/basilisp/cli_tools.lpy | 106 +++++++++++++++++++++++++------------ 1 file changed, 73 insertions(+), 33 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 3e00deb98..5f815db78 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -14,38 +14,74 @@ (declare ^:private setup-parser) -(defn ^:private args-namespace - "Create a new `argparse.Namespace` object for the argument parser. +(defn wrapped-namespace + "Wrap an `argparse.Namespace` object from the argument parser. `argparse` uses Namespace objects to collect arguments parsed from the command - line. These objects are just bags of attributes, so it needs to be able to - call `getattr`, `delattr`, and `setattr`. We store the attributes in a map in - a volatile and proxy the gets, deletes, and sets to the map. - - The returned Namespace object also implements the `IVolatile` interface to - trivially support `assoc`-like functionality on the map of options. - - Return the completed map using `deref` on the returned object." - [] - (let [m (volatile! {})] - (reify - ^:abstract argparse/Namespace - (__getattr__ [self name] - (get @m name)) - (__delattr__ [self name] - (vswap! m dissoc name)) - (__setattr__ [self name value] - (vswap! m assoc name value)) - - IVolatile - (vreset! [this new-val] - (vreset! m new-val)) - (vswap! [this f & args] - (apply vswap! m f args)) - - basilisp.lang.interfaces/IDeref - (deref [self] - @m)))) + line. These objects are just bags of attributes, so we wrap them with an + implementation of `IPersistentMap` to make them easier to integrate with idiomatic + Basilisp code." + [ns] + (reify + basilisp.lang.interfaces/IPersistentMap + (assoc [this & kvs] + (doseq [kv (partition 2 kvs) + :let [[k v] kv]] + (python/setattr ns (name k) v)) + this) + (cons [this & elems] + (doseq [elem elems] + (cond + (nil? elem) + nil + + (map? elem) + (doseq [entry (seq elem) + :let [[k v] entry]] + (python/setattr ns (name k) v)) + + (map-entry? elem) + (python/setattr ns (name (key elem)) (val elem)) + + (vector? elem) + (python/setattr ns (name (nth elem 0)) (nth elem 1)) + + (py-dict? elem) + (doseq [entry (.items elem) + :let [[k v] entry]] + (python/setattr ns (name k) v)) + + :else + (throw + (ex-info "Argument to namespace conj must be another Map or castable to MapEntry" + {:value elem + :type (python/type elem)})))) + this) + (contains [this key] + (python/hasattr ns (name key))) + (dissoc [this & ks] + (doseq [k ks] + (when (python/hasattr ns (name k)) + (python/delattr ns (name k)))) + this) + (empty [this] + (throw (python/TypeError "Cannot create empty Namespace"))) + (entry [this key] + (when (python/hasattr ns (name key)) + (map-entry key (python/getattr ns (name key))))) + (val-at [this key & args] + (let [[default] args] + (python/getattr ns (name key) default))) + (seq [this] + (->> (python/vars ns) + (.items) + (map (fn [[k v]] (map-entry (keyword k) v))))) + (__getitem__ [this key] + (python/getattr this (name key))) + (__iter__ [this] + (python/iter (python/vars this))) + (__len__ [this] + (python/len (python/vars this))))) (defn ^:private add-cli-sub-parser "Add a new command parser to the subparsers object." @@ -74,7 +110,8 @@ (reify ^:abstract argparse/Action (^{:kwargs :collect} __call__ [self _ namespace values _ _] - (vswap! namespace assoc-fn dest values)) + (-> (wrapped-namespace namespace) + (assoc-fn dest values))) (__getattr__ [self name] (get kwargs (keyword (basilisp.lang.util/demunge name)))))))) @@ -94,7 +131,8 @@ (reify ^:abstract argparse/Action (^{:kwargs :collect} __call__ [self _ namespace _ _ _] - (vswap! namespace update dest update-fn)) + (-> (wrapped-namespace namespace) + (update dest update-fn))) (__getattr__ [self name] (get kwargs (keyword (basilisp.lang.util/demunge name)))))))) @@ -305,7 +343,9 @@ ([parser-or-config args] (let [parser (cond-> parser-or-config (map? parser-or-config) (cli-parser))] - @(.parse-args parser args ** :namespace (args-namespace))))) + (-> (.parse-args parser args) + (python/vars) + (py->lisp))))) (defmacro run-cli "Create a new command-line parser using the configuration `config` and run it From 75775cdb264188b83838d03c5decedf4c992db0c Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Tue, 30 Jun 2020 08:15:09 -0400 Subject: [PATCH 22/39] Ok finally --- src/basilisp/cli_tools.lpy | 70 ++++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 5f815db78..54fce0fde 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -3,18 +3,7 @@ argparse sys)) -(defn ^:private argument-parser-kwargs - [config] - {:prog (:command config) - :description (:description config) - :usage (:usage config) - :epilog (:epilogue config) - :add-help (:include-help config true) - :allow-abbrev (:allow-abbrev config true)}) - -(declare ^:private setup-parser) - -(defn wrapped-namespace +(defn ^:private wrapped-namespace "Wrap an `argparse.Namespace` object from the argument parser. `argparse` uses Namespace objects to collect arguments parsed from the command @@ -83,14 +72,45 @@ (__len__ [this] (python/len (python/vars this))))) +(defn ^:private argument-parser-kwargs + [config] + {:prog (:command config) + :description (:description config) + :usage (:usage config) + :epilog (:epilogue config) + :add-help (:include-help config true) + :allow-abbrev (:allow-abbrev config true)}) + +(defn ^:private argument-subparser-kwargs + [config] + {:help (:description config) + :description (:description config) + :usage (:usage config) + :epilog (:epilogue config) + :add-help (:include-help config true) + :allow-abbrev (:allow-abbrev config true)}) + +(declare ^:private setup-parser) + (defn ^:private add-cli-sub-parser "Add a new command parser to the subparsers object." - [subparsers command] - (let [command-parser (->> (argument-parser-kwargs command) - (apply-method-kw subparsers add-parser (:command command)))] + [subparsers {cmd-name :command :as command}] + (let [command-parser (->> (argument-subparser-kwargs command) + (apply-method-kw subparsers add-parser cmd-name))] (setup-parser command-parser command) nil)) +(def action-default-attrs + "Actions are required to have a number of different argument defaults which are + not all provided to the action constructor. Failing to respond to these values + with a meaningful value causes `argparse` to throw exceptions in certain cases." + (->> (argparse/Action [] "dest") + (python/vars) + (.items) + (remove #{"dest"}) + (mapcat (fn [[k v]] [(keyword (basilisp.lang.util/demunge k)) v])) + (apply hash-map))) + ;; Deep inside of argparse, they create actions using the `action_class(**kwargs)` ;; syntax, so collect all arguments into keyword arguments is the safest option ;; for faking an Action class. @@ -105,15 +125,18 @@ destination key, and the parsed option and should return a new options map." [assoc-fn] (fn ^{:kwargs :collect} AssocAction [{:keys [dest] :as kwargs}] - (let [kwargs (->> (get kwargs :option-strings []) - (assoc kwargs :option-strings))] + (let [kwargs (merge action-default-attrs kwargs)] (reify ^:abstract argparse/Action (^{:kwargs :collect} __call__ [self _ namespace values _ _] (-> (wrapped-namespace namespace) (assoc-fn dest values))) (__getattr__ [self name] - (get kwargs (keyword (basilisp.lang.util/demunge name)))))))) + (let [k (keyword (basilisp.lang.util/demunge name))] + (if (contains? kwargs k) + (get kwargs k) + (throw (python/AttributeError + (str "UpdateAction does not have attribute " name)))))))))) (defn ^:private cli-tools-update-action "Create a new CLI Tools Update Action. @@ -126,15 +149,18 @@ option." [update-fn] (fn ^{:kwargs :collect} UpdateAction [{:keys [dest] :as kwargs}] - (let [kwargs (->> (get kwargs :option-strings []) - (assoc kwargs :option-strings))] + (let [kwargs (merge action-default-attrs kwargs)] (reify ^:abstract argparse/Action (^{:kwargs :collect} __call__ [self _ namespace _ _ _] (-> (wrapped-namespace namespace) (update dest update-fn))) (__getattr__ [self name] - (get kwargs (keyword (basilisp.lang.util/demunge name)))))))) + (let [k (keyword (basilisp.lang.util/demunge name))] + (if (contains? kwargs k) + (get kwargs k) + (throw (python/AttributeError + (str "UpdateAction does not have attribute " name)))))))))) (defn ^:private cli-tools-action "Return a new Action for the argument definition. @@ -198,7 +224,7 @@ "Set up the command parser from the configuration map." [parser config] (when-let [commands (:commands config)] - (let [subparsers (.add-subparsers parser)] + (let [subparsers (.add-subparsers parser ** :title "subcommands")] (doseq [command commands] (add-cli-sub-parser subparsers command)))) ;; Add arguments to the parser. Arguments can be grouped as exclusive groups From d744978dd52dbda774a3007c5348a04c0989a845 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Tue, 30 Jun 2020 08:56:17 -0400 Subject: [PATCH 23/39] Environment variables --- src/basilisp/cli_tools.lpy | 15 +++++++++++++-- src/basilisp/core.lpy | 8 ++++---- tests/basilisp/test_cli_tools.lpy | 19 ++++++++++++------- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 54fce0fde..0d0930123 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -1,6 +1,7 @@ (ns basilisp.cli-tools (:import argparse + os sys)) (defn ^:private wrapped-namespace @@ -100,7 +101,7 @@ (setup-parser command-parser command) nil)) -(def action-default-attrs +(def ^:private action-default-attrs "Actions are required to have a number of different argument defaults which are not all provided to the action constructor. Failing to respond to these values with a meaningful value causes `argparse` to throw exceptions in certain cases." @@ -199,8 +200,11 @@ ;; arguments _and_ keyword arguments, which is a challenging combination ;; to call via Basilisp, so we create a partial of the method with the ;; kwargs then (apply method args) for the potentially variadic positionals. - method (->> {:default (:default argument) + method (->> {:default (if-let [val-from-env (some-> (:env argument) (os/getenv))] + val-from-env + (:default argument)) :action (cli-tools-action argument) + :nargs (if (:update-fn argument) 0 "?") :type type-fn :metavar (when-let [metavar (:metavar argument)] (cond-> metavar @@ -313,6 +317,13 @@ ;; values will be used as-is. Default nil. :default "yes" + ;; The name of an environment variable from which to draw the + ;; default value if that environment variable exists. If the + ;; named environment variable is defined, its value will be + ;; subject to the same rules as `:default` above. This key + ;; always supersedes `:default` if it is defined. Default nil. + :env "SOME_ENV_VAR" + ;; An `assoc`-like function which will be passed the current ;; options map, the relevant destination key, and the parsed ;; option and should return a new options map. diff --git a/src/basilisp/core.lpy b/src/basilisp/core.lpy index f3113f7f2..d10f2d229 100644 --- a/src/basilisp/core.lpy +++ b/src/basilisp/core.lpy @@ -3367,8 +3367,8 @@ is nil or there are no more forms." [x & forms] (if (seq forms) - `(let [result# (-> ~x ~(first forms))] - (when-not (nil? result#) + `(when-not (nil? ~x) + (let [result# (-> ~x ~(first forms))] (some-> result# ~@(next forms)))) x)) @@ -3377,8 +3377,8 @@ is nil or there are no more forms." [x & forms] (if (seq forms) - `(let [result# (->> ~x ~(first forms))] - (when-not (nil? result#) + `(when-not (nil? ~x) + (let [result# (->> ~x ~(first forms))] (some->> result# ~@(next forms)))) x)) diff --git a/tests/basilisp/test_cli_tools.lpy b/tests/basilisp/test_cli_tools.lpy index 22a445c46..beff71af0 100644 --- a/tests/basilisp/test_cli_tools.lpy +++ b/tests/basilisp/test_cli_tools.lpy @@ -55,13 +55,18 @@ :arguments [] :commands [{:command "run" :description "run a Basilisp script or code" - :arguments [{:name "file-or-code"} - {:flags ["-c" "--code"] - :help "if provided, treat argument as a string of code"} + :arguments [{:name "file-or-code" + :help "the filename or, if using -c the code, to execute"} + {:flags ["-c" "--code"] + :help "if provided, treat argument as a string of code" + :default false + :update-fn (constantly true)} - {:flags ["--warn-on-shadowed-name"] - :help "if provided, emit warnings if a local name is shadowed by another local name" - :group :compiler-flags} + {:flags ["--warn-on-shadowed-name"] + :help "if provided, emit warnings if a local name is shadowed by another local name" + :env "BASILISP_WARN_ON_SHADOWED_NAME" + :group "compiler flags"} {:flags ["--warn-on-shadowed-var"] :help "if provided, emit warnings if a Var name is shadowed by a local name" - :group :compiler-flags}]}]})) + :env "BASILISP_WARN_ON_SHADOWED_VAR" + :group "compiler flags"}]}]})) From 431e1afd1c22459930eba083ebbfb6749fb5f5eb Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Tue, 30 Jun 2020 08:59:41 -0400 Subject: [PATCH 24/39] Right the log --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 142f809af..add5926b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added the current Python version (`:lpy36`, `:lpy37`, etc.) as a default reader feature for reader conditionals (#585) * Added default reader features for matching Python version ranges (`:lpy36+`, `:lpy38-`, etc.) (#593) * Added `lazy-cat` function for lazily concatenating sequences (#588) + * Added CLI argument parser in `basilisp.cli-tools` namespace (#535) ### Changed * Moved `basilisp.lang.runtime.to_seq` to `basilisp.lang.seq` so it can be used within that module and by `basilisp.lang.runtime` without circular import (#588) @@ -43,7 +44,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added support for generically diffing Basilisp data structures in `basilisp.data` namespace (#555) * Added support for artificially abstract bases classes in `deftype`, `defrecord`, and `reify` types (#565) * Added support for transient maps, sets, and vectors (#568) - * Added CLI argument parser in `basilisp.cli-tools` namespace (#535) ### Changed * Basilisp set and map types are now backed by the HAMT provided by `immutables` (#557) From ff08273bfcaab0ffa401d4ca2acba8c1194598d2 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Tue, 30 Jun 2020 19:12:22 -0400 Subject: [PATCH 25/39] Add more spec --- src/basilisp/cli_tools.lpy | 44 +++++++++++++++++++++++++++++-- tests/basilisp/test_cli_tools.lpy | 18 +++++++++---- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 0d0930123..309a9b1d1 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -84,8 +84,9 @@ (defn ^:private argument-subparser-kwargs [config] - {:help (:description config) + {:help (:help config) :description (:description config) + :aliases (:aliases config) :usage (:usage config) :epilog (:epilogue config) :add-help (:include-help config true) @@ -360,7 +361,46 @@ :help "Add extra foos into the bars."}] ;; Subcommands which will be added to the parser. Default nil. - :commands []}) + :commands [{;; The name of the command as it should appear in the CLI. Required. + :command "do-foo" + + ;; A vector of valid aliases which will be accepted for the + ;; subcommand. Default []. + :aliases ["foo" "df"] + + ;; The description which appears immediately under the usage text + ;; for this subcommand's help text. Optional. + :description "This command does the foo." + + ;; The short help text which appears beside this subcommand on + ;; the parent command's help text. Optional. + :help "Does the foo" + + ;; The usage message emitted when invalid arguments are provided or + ;; when printing the help text. If nil is provided, the usage + ;; message will be calculated automatically by the arguments and + ;; commands provided. + :usage "%(prog)s [options]" + + ;; The text which appears beneath the generated help message. + ;; Optional. + :epilogue "" + + ;; If true, include the `-h/--help` commands by default. Default + ;; true. + :include-help true + + ;; If true, allow abbreviations for unambiguous partial flags. + ;; Default true. + :allow-abbrev true + + ;; Arguments which apply to this specific subcommand. Default nil. + :arguments [] + + ;; Subcommands which will be added to the parser for this + ;; subcommand (which can have it's own subcommands and so on...). + ;; Default nil. + :commands []}]}) (defn cli-parser "Create a CLI argument parser from the configuration map. diff --git a/tests/basilisp/test_cli_tools.lpy b/tests/basilisp/test_cli_tools.lpy index beff71af0..ecd493d57 100644 --- a/tests/basilisp/test_cli_tools.lpy +++ b/tests/basilisp/test_cli_tools.lpy @@ -48,13 +48,21 @@ (->> (update base-parser-config :arguments conj)) (cli/cli-parser)))))) +(defmacro parse-args + [args] + `(with-out-str + (try + (cli/parse-args parser ~args) + (catch python/SystemExit _ nil)))) + (def parser (cli/cli-parser {:command "basilisp" :description "Basilisp is a Lisp dialect inspired by Clojure targeting Python 3" :arguments [] :commands [{:command "run" - :description "run a Basilisp script or code" + :description "Run a Basilisp script from a file or run Basilisp code directly." + :help "run a Basilisp script or code" :arguments [{:name "file-or-code" :help "the filename or, if using -c the code, to execute"} {:flags ["-c" "--code"] @@ -62,10 +70,10 @@ :default false :update-fn (constantly true)} - {:flags ["--warn-on-shadowed-name"] - :help "if provided, emit warnings if a local name is shadowed by another local name" - :env "BASILISP_WARN_ON_SHADOWED_NAME" - :group "compiler flags"} + {:flags ["--warn-on-shadowed-name"] + :help "if provided, emit warnings if a local name is shadowed by another local name" + :env "BASILISP_WARN_ON_SHADOWED_NAME" + :group "compiler flags"} {:flags ["--warn-on-shadowed-var"] :help "if provided, emit warnings if a Var name is shadowed by a local name" :env "BASILISP_WARN_ON_SHADOWED_VAR" From 4b878a148b0a96c404ce06793816002ea732b35f Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Tue, 30 Jun 2020 19:18:02 -0400 Subject: [PATCH 26/39] Alias better --- src/basilisp/cli_tools.lpy | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 309a9b1d1..7c7cac3ef 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -83,14 +83,14 @@ :allow-abbrev (:allow-abbrev config true)}) (defn ^:private argument-subparser-kwargs - [config] - {:help (:help config) - :description (:description config) - :aliases (:aliases config) - :usage (:usage config) - :epilog (:epilogue config) - :add-help (:include-help config true) - :allow-abbrev (:allow-abbrev config true)}) + [{:keys [aliases] :as config}] + (cond-> {:help (:help config) + :description (:description config) + :usage (:usage config) + :epilog (:epilogue config) + :add-help (:include-help config true) + :allow-abbrev (:allow-abbrev config true)} + aliases (assoc :aliases aliases))) (declare ^:private setup-parser) From 8d0d00911f474a9df19a7f2e222b6899a45b6180 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Fri, 3 Jul 2020 16:57:01 -0400 Subject: [PATCH 27/39] Support handlers and kebab-case all keys in parsed args --- src/basilisp/cli_tools.lpy | 158 ++++++++++++++++++++++++------ src/basilisp/core.lpy | 5 + tests/basilisp/test_cli_tools.lpy | 6 ++ 3 files changed, 137 insertions(+), 32 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 7c7cac3ef..411f74678 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -110,7 +110,7 @@ (python/vars) (.items) (remove #{"dest"}) - (mapcat (fn [[k v]] [(keyword (basilisp.lang.util/demunge k)) v])) + (mapcat (fn [[k v]] [(keyword (demunge k)) v])) (apply hash-map))) ;; Deep inside of argparse, they create actions using the `action_class(**kwargs)` @@ -134,7 +134,7 @@ (-> (wrapped-namespace namespace) (assoc-fn dest values))) (__getattr__ [self name] - (let [k (keyword (basilisp.lang.util/demunge name))] + (let [k (keyword (demunge name))] (if (contains? kwargs k) (get kwargs k) (throw (python/AttributeError @@ -158,7 +158,7 @@ (-> (wrapped-namespace namespace) (update dest update-fn))) (__getattr__ [self name] - (let [k (keyword (basilisp.lang.util/demunge name))] + (let [k (keyword (demunge name))] (if (contains? kwargs k) (get kwargs k) (throw (python/AttributeError @@ -171,18 +171,35 @@ an exception will be thrown. If neither is provided, a default assoc action will be returned using `assoc` as the function." [{:keys [assoc-fn update-fn]}] - (when (and assoc-fn update-fn) - (throw - (ex-info (str "Arguments may only specify either an :assoc-fn or " - "an :update-fn, not both") - {:assoc-fn assoc-fn - :update-fn update-fn}))) (if update-fn (cli-tools-update-action update-fn) (cli-tools-assoc-action (or assoc-fn assoc)))) +(defn ^:private validate-argument + [{:keys [name flags nargs assoc-fn update-fn] :as argument}] + (when (and name flags) + (throw + (ex-info (str "Arguments may either be positional (via :name) or " + "optional (via :flags), not both") + {:name name + :flags flags}))) + (when update-fn + (when assoc-fn + (throw + (ex-info (str "Arguments may only specify either an :assoc-fn or " + "an :update-fn, not both") + {:assoc-fn assoc-fn + :update-fn update-fn}))) + (when nargs + (throw + (ex-info (str "Arguments may only specify either an :update-fn or " + ":nargs, not both") + {:assoc-fn assoc-fn + :update-fn update-fn}))))) + (defn ^:private add-argument [parser argument] + (validate-argument argument) (let [;; ArgumentParser has a "type" function which can convert the string value ;; from the CLI into its final value. We decompose that into a parse and ;; validate step in the argument config and re-compose those steps here. @@ -197,15 +214,20 @@ validate-fn (comp validate-fn)) validate-fn) + default (if-let [val-from-env (some-> (:env argument) (os/getenv))] + val-from-env + (:default argument)) + ;; Python's ArgumentParser.add_argument method takes variadic positional ;; arguments _and_ keyword arguments, which is a challenging combination ;; to call via Basilisp, so we create a partial of the method with the ;; kwargs then (apply method args) for the potentially variadic positionals. - method (->> {:default (if-let [val-from-env (some-> (:env argument) (os/getenv))] - val-from-env - (:default argument)) + method (->> {:default default + :const default :action (cli-tools-action argument) - :nargs (if (:update-fn argument) 0 "?") + :nargs (if-let [nargs (:nargs argument)] + (cond-> nargs (keyword? nargs) (name)) + (if (:update-fn argument) 0 "?")) :type type-fn :metavar (when-let [metavar (:metavar argument)] (cond-> metavar @@ -214,17 +236,22 @@ (partial-kw (.-add-argument parser))) name (:name argument) flags (:flags argument)] - (when (and name flags) - (throw - (ex-info (str "Arguments may either be positional (via :name) or " - "optional (via :flags), not both") - {:name name - :flags flags}))) (if name (method name) (apply method flags)) nil)) +(def ^:dynamic *create-default-handler* + "A function of one argument expecting an `argparse/ArgumentParser` instance + which returns a function of one argument which will receive the parsed arguments + from the CLI. + + Override this to control how default handlers are generated." + (fn [parser] + (fn [_] + (.print-help parser) + (sys/exit 1)))) + (defn ^:private setup-parser "Set up the command parser from the configuration map." [parser config] @@ -265,6 +292,10 @@ ;; All other arguments (doseq [argument (:normal groups)] (add-argument parser argument)))) + ;; Configure either the given handler or a default handler. + (let [handler (or (:handler config) + (*create-default-handler* parser))] + (.set-defaults parser ** :handler handler)) nil) ;; Config map spec @@ -278,6 +309,14 @@ ;; The description which appears immediately under the usage text. Optional. :description "This command foos the bars." + ;; A function of one argument which receives the parsed arguments as a map + ;; and which can direct further action based on the arguments. Handlers + ;; can be defined on the top-level command and on any subcommands. If no + ;; handler is defined, a default one will be provided which prints the help + ;; text for the command or subcommand and exits the program with a non-zero + ;; error code. + :handler identity + ;; The usage message emitted when invalid arguments are provided or when ;; printing the help text. If nil is provided, the usage message will be ;; calculated automatically by the arguments and commands provided. @@ -301,6 +340,12 @@ ;; the `:name` key. :flags ["-f" "--foo"] + ;; The style of argument. Arguments may be defined in one of three + ;; styles. `:positional` arguments are the default type for + ;; positional style arguments. They consume 1 argument from the + ;; input and + :style :flag + ;; A function of one string argument which returns the intended ;; final value. Default nil. :parse-fn int @@ -341,7 +386,7 @@ :update-fn (fnil inc 0) ;; Either a string value or vector of strings used to refer to - ;; expected arguments generated help text message. + ;; expected arguments generated help text message. Optional. :metavar "FOO" ;; Arguments may be specified to be part of an exclusive group, @@ -376,6 +421,10 @@ ;; the parent command's help text. Optional. :help "Does the foo" + ;; A function of one argument with the same semantics as described + ;; in the top-level command `:handler` key. + :handler identity + ;; The usage message emitted when invalid arguments are provided or ;; when printing the help text. If nil is provided, the usage ;; message will be calculated automatically by the arguments and @@ -402,15 +451,35 @@ ;; Default nil. :commands []}]}) +(defn file-type + "Return a type function for a file-typed argument. The `mode` must be specified + and must be one of the allowed modes for Python's `open` builtin. + + If the user supplies the value '-', this function intelligently opens either + `*in*` for read-mode files or `*out*` for write-mode files. + + This function also supports the `bufsize`, `encoding`, and `errors` options of + `open`." + ([mode] + (argparse/FileType mode)) + ([mode & {:as opts}] + (apply-kw argparse/FileType mode opts))) + +(defn ^:private cli-parser? + [o] + (instance? argparse/ArgumentParser o)) + (defn cli-parser "Create a CLI argument parser from the configuration map. Configuration maps have the following spec" - [config] - (let [parser (->> (argument-parser-kwargs config) - (apply-kw argparse/ArgumentParser))] - (setup-parser parser config) - parser)) + [parser-or-config] + (if (cli-parser? parser-or-config) + parser-or-config + (doto (->> parser-or-config + (argument-parser-kwargs) + (apply-kw argparse/ArgumentParser)) + (setup-parser parser-or-config)))) (defn parse-args "Parse command line arguments using the supplied parser or CLI tools configuration @@ -418,15 +487,40 @@ ([parser-or-config] (parse-args parser-or-config sys/argv)) ([parser-or-config args] - (let [parser (cond-> parser-or-config - (map? parser-or-config) (cli-parser))] - (-> (.parse-args parser args) - (python/vars) - (py->lisp))))) + (-> (cli-parser parser-or-config) + (.parse-args args) + (python/vars) + (.items) + (->> (reduce (fn [m [k v]] + (assoc! m (keyword (demunge k)) v)) + (transient {}))) + (persistent!)))) + +(defn ^:private execute-handler + "Execute the handler defined in the arguments. + + If no handler is defined, print the help for the parser and exit with error code 1." + [{:keys [handler] :as parsed-args}] + (handler parsed-args)) + +(defn handle-args + "Parse command line arguments using the supplied parser or CLI tools configuration + map (as by `parse-args`) and execute any command or sub-command handlers for the + parsed arguments. + + If no `args` are given, parse the arguments from Python's `sys/argv`." + ([parser-or-config] + (-> (cli-parser parser-or-config) + (parse-args) + (execute-handler))) + ([parser-or-config args] + (->(cli-parser parser-or-config) + (parse-args args) + (execute-handler)))) (defmacro run-cli "Create a new command-line parser using the configuration `config` and run it automatically if this namespace is called as the Python `__main__`." [config] - `(when (= --name-- "__main__") - (parse-args ~config))) + `(when (= __name__ "__main__") + (handle-args ~config))) diff --git a/src/basilisp/core.lpy b/src/basilisp/core.lpy index d10f2d229..452c09f14 100644 --- a/src/basilisp/core.lpy +++ b/src/basilisp/core.lpy @@ -4661,6 +4661,11 @@ [s] (basilisp.lang.util/munge s)) +(defn demunge + "De-munge a Python-safe string identifier into a Lisp identifier." + [s] + (basilisp.lang.util/demunge s)) + (def namespace-munge "Convert a Basilisp namespace name to a valid Python name." munge) diff --git a/tests/basilisp/test_cli_tools.lpy b/tests/basilisp/test_cli_tools.lpy index ecd493d57..fc09bb6af 100644 --- a/tests/basilisp/test_cli_tools.lpy +++ b/tests/basilisp/test_cli_tools.lpy @@ -63,8 +63,12 @@ :commands [{:command "run" :description "Run a Basilisp script from a file or run Basilisp code directly." :help "run a Basilisp script or code" + :handler identity :arguments [{:name "file-or-code" :help "the filename or, if using -c the code, to execute"} + {:flags ["--in-ns"] + :help "namespace to run the code in" + :default "basilisp.user"} {:flags ["-c" "--code"] :help "if provided, treat argument as a string of code" :default false @@ -72,9 +76,11 @@ {:flags ["--warn-on-shadowed-name"] :help "if provided, emit warnings if a local name is shadowed by another local name" + :nargs 0 :env "BASILISP_WARN_ON_SHADOWED_NAME" :group "compiler flags"} {:flags ["--warn-on-shadowed-var"] :help "if provided, emit warnings if a Var name is shadowed by a local name" + :nargs 0 :env "BASILISP_WARN_ON_SHADOWED_VAR" :group "compiler flags"}]}]})) From 8a31ee7b0dd3e5a32dc60e283296e86c24c589a1 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sun, 5 Jul 2020 17:21:24 -0400 Subject: [PATCH 28/39] Test things --- tests/basilisp/test_cli_tools.lpy | 84 ++++++++++++++++++------------- 1 file changed, 50 insertions(+), 34 deletions(-) diff --git a/tests/basilisp/test_cli_tools.lpy b/tests/basilisp/test_cli_tools.lpy index fc09bb6af..37f81da58 100644 --- a/tests/basilisp/test_cli_tools.lpy +++ b/tests/basilisp/test_cli_tools.lpy @@ -48,39 +48,55 @@ (->> (update base-parser-config :arguments conj)) (cli/cli-parser)))))) -(defmacro parse-args - [args] - `(with-out-str - (try - (cli/parse-args parser ~args) - (catch python/SystemExit _ nil)))) +(defn parse-args + [parser args] + (try + (cli/parse-args parser args) + (catch python/SystemExit _ nil))) -(def parser - (cli/cli-parser - {:command "basilisp" - :description "Basilisp is a Lisp dialect inspired by Clojure targeting Python 3" - :arguments [] - :commands [{:command "run" - :description "Run a Basilisp script from a file or run Basilisp code directly." - :help "run a Basilisp script or code" - :handler identity - :arguments [{:name "file-or-code" - :help "the filename or, if using -c the code, to execute"} - {:flags ["--in-ns"] - :help "namespace to run the code in" - :default "basilisp.user"} - {:flags ["-c" "--code"] - :help "if provided, treat argument as a string of code" - :default false - :update-fn (constantly true)} +(deftest cli-parser + (let [parser (cli/cli-parser + {:command "basilisp" + :description "Basilisp is a Lisp dialect inspired by Clojure targeting Python 3" + :arguments [] + :commands [{:command "run" + :description "Run a Basilisp script from a file or run Basilisp code directly." + :help "run a Basilisp script or code" + :handler identity + :arguments [{:name "file-or-code" + :help "the filename or, if using -c the code, to execute"} + {:flags ["--in-ns"] + :help "namespace to run the code in" + :default "basilisp.user"} + {:flags ["-c" "--code"] + :help "if provided, treat argument as a string of code" + :default false + :update-fn (constantly true)} - {:flags ["--warn-on-shadowed-name"] - :help "if provided, emit warnings if a local name is shadowed by another local name" - :nargs 0 - :env "BASILISP_WARN_ON_SHADOWED_NAME" - :group "compiler flags"} - {:flags ["--warn-on-shadowed-var"] - :help "if provided, emit warnings if a Var name is shadowed by a local name" - :nargs 0 - :env "BASILISP_WARN_ON_SHADOWED_VAR" - :group "compiler flags"}]}]})) + {:flags ["--warn-on-shadowed-name"] + :help "if provided, emit warnings if a local name is shadowed by another local name" + :nargs 0 + :env "BASILISP_WARN_ON_SHADOWED_NAME" + :group "compiler flags"} + {:flags ["--warn-on-shadowed-var"] + :help "if provided, emit warnings if a Var name is shadowed by a local name" + :nargs 0 + :env "BASILISP_WARN_ON_SHADOWED_VAR" + :group "compiler flags"}]}]})] + (are [args ret] (is (= ret (dissoc (parse-args parser args) :handler))) + [] {} + ["run" "--in-ns" "basilisp.cli-tools" "cli_tools.lpy"] {:file-or-code "cli_tools.lpy" + :code false + :in-ns "basilisp.cli-tools" + :warn-on-shadowed-var nil + :warn-on-shadowed-name nil} + ["run" "--warn-on-shadowed-name" "-c" "(identity 1)"] {:file-or-code "(identity 1)" + :code true + :in-ns "basilisp.user" + :warn-on-shadowed-var nil + :warn-on-shadowed-name true} + ["run" "--code" "(identity 1)"] {:file-or-code "(identity 1)" + :code true + :in-ns "basilisp.user" + :warn-on-shadowed-var nil + :warn-on-shadowed-name nil}))) From 9ae3d78e28b782d8716fa17f6eea70b3cfbe4acd Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sun, 5 Jul 2020 17:21:38 -0400 Subject: [PATCH 29/39] Oh who knows --- src/basilisp/cli_tools.lpy | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 411f74678..6b076a8f8 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -214,26 +214,18 @@ validate-fn (comp validate-fn)) validate-fn) - default (if-let [val-from-env (some-> (:env argument) (os/getenv))] - val-from-env - (:default argument)) + ;; Prepare the keyword arguments for ArgumentParser.add_argument:type type-fn + :metavar (when-let [metavar (:metavar argument)] + (cond-> metavar + (vector? metavar) (python/tuple))) + :help (:help argument)} + ) ;; Python's ArgumentParser.add_argument method takes variadic positional ;; arguments _and_ keyword arguments, which is a challenging combination ;; to call via Basilisp, so we create a partial of the method with the ;; kwargs then (apply method args) for the potentially variadic positionals. - method (->> {:default default - :const default - :action (cli-tools-action argument) - :nargs (if-let [nargs (:nargs argument)] - (cond-> nargs (keyword? nargs) (name)) - (if (:update-fn argument) 0 "?")) - :type type-fn - :metavar (when-let [metavar (:metavar argument)] - (cond-> metavar - (vector? metavar) (python/tuple))) - :help (:help argument)} - (partial-kw (.-add-argument parser))) + method (partial-kw (.-add-argument parser) kwargs) name (:name argument) flags (:flags argument)] (if name @@ -340,11 +332,7 @@ ;; the `:name` key. :flags ["-f" "--foo"] - ;; The style of argument. Arguments may be defined in one of three - ;; styles. `:positional` arguments are the default type for - ;; positional style arguments. They consume 1 argument from the - ;; input and - :style :flag + :nargs nil ;; A function of one string argument which returns the intended ;; final value. Default nil. From b57390ff3a89700546ff7aab17b1c092a9ecff08 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Thu, 24 Dec 2020 12:03:42 -0500 Subject: [PATCH 30/39] Fix the badthing --- src/basilisp/cli_tools.lpy | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 6b076a8f8..6e4f1b872 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -214,12 +214,21 @@ validate-fn (comp validate-fn)) validate-fn) - ;; Prepare the keyword arguments for ArgumentParser.add_argument:type type-fn - :metavar (when-let [metavar (:metavar argument)] - (cond-> metavar - (vector? metavar) (python/tuple))) - :help (:help argument)} - ) + ;; Prepare the keyword arguments for ArgumentParser.add_argument + default (if-let [val-from-env (some-> (:env argument) (os/getenv))] + val-from-env + (:default argument)) + kwargs {:default default + :const default + :action (cli-tools-action argument) + :nargs (if-let [nargs (:nargs argument)] + (cond-> nargs (keyword? nargs) (name)) + (if (:update-fn argument) 0 "?")) + :type type-fn + :metavar (when-let [metavar (:metavar argument)] + (cond-> metavar + (vector? metavar) (python/tuple))) + :help (:help argument)} ;; Python's ArgumentParser.add_argument method takes variadic positional ;; arguments _and_ keyword arguments, which is a challenging combination From f26ea890547492c51c26615b282856674b32c305 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Thu, 24 Dec 2020 12:38:40 -0500 Subject: [PATCH 31/39] Do a different --- src/basilisp/cli_tools.lpy | 8 +++----- tests/basilisp/test_cli_tools.lpy | 2 -- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/cli_tools.lpy index 6e4f1b872..992b39e60 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/cli_tools.lpy @@ -215,11 +215,9 @@ validate-fn) ;; Prepare the keyword arguments for ArgumentParser.add_argument - default (if-let [val-from-env (some-> (:env argument) (os/getenv))] - val-from-env - (:default argument)) - kwargs {:default default - :const default + kwargs {:default (or (some-> (:env argument) (os/getenv)) + (:default argument)) + :const true :action (cli-tools-action argument) :nargs (if-let [nargs (:nargs argument)] (cond-> nargs (keyword? nargs) (name)) diff --git a/tests/basilisp/test_cli_tools.lpy b/tests/basilisp/test_cli_tools.lpy index 37f81da58..3481348bb 100644 --- a/tests/basilisp/test_cli_tools.lpy +++ b/tests/basilisp/test_cli_tools.lpy @@ -75,12 +75,10 @@ {:flags ["--warn-on-shadowed-name"] :help "if provided, emit warnings if a local name is shadowed by another local name" - :nargs 0 :env "BASILISP_WARN_ON_SHADOWED_NAME" :group "compiler flags"} {:flags ["--warn-on-shadowed-var"] :help "if provided, emit warnings if a Var name is shadowed by a local name" - :nargs 0 :env "BASILISP_WARN_ON_SHADOWED_VAR" :group "compiler flags"}]}]})] (are [args ret] (is (= ret (dissoc (parse-args parser args) :handler))) From 713cb0fcea879a4bad58915f9e63a39dbcaa495d Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Thu, 24 Dec 2020 12:40:27 -0500 Subject: [PATCH 32/39] Wow maybe we're the baddies --- tests/basilisp/test_cli_tools.lpy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/basilisp/test_cli_tools.lpy b/tests/basilisp/test_cli_tools.lpy index 3481348bb..14a5bf0ea 100644 --- a/tests/basilisp/test_cli_tools.lpy +++ b/tests/basilisp/test_cli_tools.lpy @@ -81,7 +81,7 @@ :help "if provided, emit warnings if a Var name is shadowed by a local name" :env "BASILISP_WARN_ON_SHADOWED_VAR" :group "compiler flags"}]}]})] - (are [args ret] (is (= ret (dissoc (parse-args parser args) :handler))) + (are [args ret] (= ret (dissoc (parse-args parser args) :handler)) [] {} ["run" "--in-ns" "basilisp.cli-tools" "cli_tools.lpy"] {:file-or-code "cli_tools.lpy" :code false From ebcc482c9b72f1aa4fe34bd11a8bf3fd9b83a855 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Sun, 31 Dec 2023 18:07:19 -0500 Subject: [PATCH 33/39] Contrib --- CHANGELOG.md | 2 +- src/basilisp/{ => contrib}/cli_tools.lpy | 2 +- tests/basilisp/{ => contrib}/test_cli_tools.lpy | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/basilisp/{ => contrib}/cli_tools.lpy (99%) rename tests/basilisp/{ => contrib}/test_cli_tools.lpy (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d77a35ec2..72cb4430f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added * Added support for passing through `:tag` metadata to the generated Python AST (#354) - * Added CLI argument parser in `basilisp.cli-tools` namespace (#535) + * Added CLI argument parser in `basilisp.contrib.cli-tools` namespace (#535) ### Changed * Optimize calls to Python's `operator` module into their corresponding native operators (#754) diff --git a/src/basilisp/cli_tools.lpy b/src/basilisp/contrib/cli_tools.lpy similarity index 99% rename from src/basilisp/cli_tools.lpy rename to src/basilisp/contrib/cli_tools.lpy index 992b39e60..749560b5f 100644 --- a/src/basilisp/cli_tools.lpy +++ b/src/basilisp/contrib/cli_tools.lpy @@ -1,4 +1,4 @@ -(ns basilisp.cli-tools +(ns basilisp.contrib.cli-tools (:import argparse os diff --git a/tests/basilisp/test_cli_tools.lpy b/tests/basilisp/contrib/test_cli_tools.lpy similarity index 98% rename from tests/basilisp/test_cli_tools.lpy rename to tests/basilisp/contrib/test_cli_tools.lpy index 14a5bf0ea..1636d6ba9 100644 --- a/tests/basilisp/test_cli_tools.lpy +++ b/tests/basilisp/contrib/test_cli_tools.lpy @@ -1,6 +1,6 @@ -(ns tests.basilisp.test-cli-tools +(ns tests.basilisp.contrib.test-cli-tools (:require - [basilisp.cli-tools :as cli] + [basilisp.contrib.cli-tools :as cli] [basilisp.test :refer [deftest are is testing]])) (def ^:private base-parser-config From 4874215b3d177a63771e8dd0acede3749e196ccb Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Mon, 1 Jan 2024 16:07:09 -0500 Subject: [PATCH 34/39] Lots of cool new stuff --- docs/api/contrib/cli-tools.rst | 10 ++ src/basilisp/cli.py | 17 ++- src/basilisp/contrib/cli_tools.lpy | 122 +++++++++++++--------- src/basilisp/lang/runtime.py | 17 +++ tests/basilisp/contrib/test_cli_tools.lpy | 106 +++++++++++-------- 5 files changed, 178 insertions(+), 94 deletions(-) create mode 100644 docs/api/contrib/cli-tools.rst diff --git a/docs/api/contrib/cli-tools.rst b/docs/api/contrib/cli-tools.rst new file mode 100644 index 000000000..202d2eb3a --- /dev/null +++ b/docs/api/contrib/cli-tools.rst @@ -0,0 +1,10 @@ +basilisp.contrib.cli-tools +========================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +.. autonamespace:: basilisp.contrib.cli-tools + :members: + :undoc-members: \ No newline at end of file diff --git a/src/basilisp/cli.py b/src/basilisp/cli.py index d6ff9d931..4d35f0076 100644 --- a/src/basilisp/cli.py +++ b/src/basilisp/cli.py @@ -13,6 +13,7 @@ from basilisp.lang import reader as reader from basilisp.lang import runtime as runtime from basilisp.lang import symbol as sym +from basilisp.lang import vector as vec from basilisp.prompt import get_prompter CLI_INPUT_FILE_PATH = "" @@ -417,6 +418,11 @@ def run( with runtime.ns_bindings(args.in_ns) as ns: ns.refer_all(core_ns) + if args.args: + cli_args_var = core_ns.find(sym.symbol(runtime.COMMAND_LINE_ARGS_VAR_NAME)) + assert cli_args_var is not None + cli_args_var.bind_root(vec.vector(args.args)) + if args.code: eval_str(args.file_or_code, ctx, ns, eof) elif args.file_or_code == STDIN_FILE_NAME: @@ -445,6 +451,11 @@ def _add_run_subcommand(parser: argparse.ArgumentParser): parser.add_argument( "--in-ns", default=runtime.REPL_DEFAULT_NS, help="namespace to use for the code" ) + parser.add_argument( + "args", + nargs=argparse.REMAINDER, + help="command line args made accessible to the script as basilisp.core/*command-line-args*", + ) _add_compiler_arg_group(parser) _add_debug_arg_group(parser) @@ -490,7 +501,11 @@ def run_script(): The current process is replaced as by `os.execlp`.""" # os.exec* functions do not perform shell expansion, so we must do so manually. script_path = Path(sys.argv[1]).resolve() - os.execlp("basilisp", "basilisp", "run", script_path) + args = ["basilisp", "run", script_path] + if rest := sys.argv[2:]: + args.append("--") + args.extend(rest) + os.execvp("basilisp", args) def invoke_cli(args: Optional[Sequence[str]] = None) -> None: diff --git a/src/basilisp/contrib/cli_tools.lpy b/src/basilisp/contrib/cli_tools.lpy index 749560b5f..0059de2d7 100644 --- a/src/basilisp/contrib/cli_tools.lpy +++ b/src/basilisp/contrib/cli_tools.lpy @@ -1,15 +1,16 @@ (ns basilisp.contrib.cli-tools (:import argparse + functools os sys)) (defn ^:private wrapped-namespace - "Wrap an `argparse.Namespace` object from the argument parser. + "Wrap an ``argparse.Namespace`` object from the argument parser. - `argparse` uses Namespace objects to collect arguments parsed from the command + ``argparse`` uses Namespace objects to collect arguments parsed from the command line. These objects are just bags of attributes, so we wrap them with an - implementation of `IPersistentMap` to make them easier to integrate with idiomatic + implementation of ``IPersistentMap`` to make them easier to integrate with idiomatic Basilisp code." [ns] (reify @@ -75,21 +76,23 @@ (defn ^:private argument-parser-kwargs [config] - {:prog (:command config) - :description (:description config) - :usage (:usage config) - :epilog (:epilogue config) - :add-help (:include-help config true) - :allow-abbrev (:allow-abbrev config true)}) + {:prog (:command config) + :description (:description config) + :usage (:usage config) + :epilog (:epilogue config) + :add-help (:include-help config true) + :allow-abbrev (:allow-abbrev config true) + :exit-on-error (:exit-on-error config true)}) (defn ^:private argument-subparser-kwargs [{:keys [aliases] :as config}] - (cond-> {:help (:help config) - :description (:description config) - :usage (:usage config) - :epilog (:epilogue config) - :add-help (:include-help config true) - :allow-abbrev (:allow-abbrev config true)} + (cond-> {:help (:help config) + :description (:description config) + :usage (:usage config) + :epilog (:epilogue config) + :add-help (:include-help config true) + :allow-abbrev (:allow-abbrev config true) + :exit-on-error (:exit-on-error config true)} aliases (assoc :aliases aliases))) (declare ^:private setup-parser) @@ -105,7 +108,7 @@ (def ^:private action-default-attrs "Actions are required to have a number of different argument defaults which are not all provided to the action constructor. Failing to respond to these values - with a meaningful value causes `argparse` to throw exceptions in certain cases." + with a meaningful value causes ``argparse`` to throw exceptions in certain cases." (->> (argparse/Action [] "dest") (python/vars) (.items) @@ -120,10 +123,10 @@ (defn ^:private cli-tools-assoc-action "Create a new CLI Tools Assoc Action. - Add actions allow users to define actions with `assoc`-like semantics on the + Add actions allow users to define actions with :lpy:fn:`assoc`-like semantics on the parsed options map. - The provided `assoc-fn` will be passed the entire options map, the relevant + The provided ``assoc-fn`` will be passed the entire options map, the relevant destination key, and the parsed option and should return a new options map." [assoc-fn] (fn ^{:kwargs :collect} AssocAction [{:keys [dest] :as kwargs}] @@ -146,8 +149,8 @@ Update actions allow users to define actions which update the current value stored in the options map. - The provided `update-fn` will be passed the current value of the given option - (or `nil` if no default is provided) and should return a new value for the + The provided ``update-fn`` will be passed the current value of the given option + (or ``nil`` if no default is provided) and should return a new value for the option." [update-fn] (fn ^{:kwargs :collect} UpdateAction [{:keys [dest] :as kwargs}] @@ -167,9 +170,9 @@ (defn ^:private cli-tools-action "Return a new Action for the argument definition. - Users may provide at most one of `:assoc-fn` or `:update-fn`. If both are given, + Users may provide at most one of ``:assoc-fn`` or ``:update-fn``. If both are given, an exception will be thrown. If neither is provided, a default assoc action will - be returned using `assoc` as the function." + be returned using :lpy:fn:`assoc` as the function." [{:keys [assoc-fn update-fn]}] (if update-fn (cli-tools-update-action update-fn) @@ -204,14 +207,18 @@ ;; from the CLI into its final value. We decompose that into a parse and ;; validate step in the argument config and re-compose those steps here. validate-fn (when-let [[f msg] (:validate argument)] - (fn [v] - (if-not (f v) - (throw - (argparse/ArgumentTypeError msg)) - v))) + (functools/update-wrapper + (fn [v] + (if-not (f v) + (throw + (argparse/ArgumentTypeError msg)) + v)) + f)) type-fn (if-let [parse-fn (:parse-fn argument)] - (cond->> parse-fn - validate-fn (comp validate-fn)) + (if validate-fn + (-> (comp validate-fn parse-fn) + (functools/update-wrapper parse-fn)) + parse-fn) validate-fn) ;; Prepare the keyword arguments for ArgumentParser.add_argument @@ -241,7 +248,7 @@ nil)) (def ^:dynamic *create-default-handler* - "A function of one argument expecting an `argparse/ArgumentParser` instance + "A function of one argument expecting an ``argparse/ArgumentParser`` instance which returns a function of one argument which will receive the parsed arguments from the CLI. @@ -447,14 +454,14 @@ :commands []}]}) (defn file-type - "Return a type function for a file-typed argument. The `mode` must be specified - and must be one of the allowed modes for Python's `open` builtin. + "Return a type function for a file-typed argument. The ``mode`` must be specified + and must be one of the allowed modes for Python's ``open`` builtin. If the user supplies the value '-', this function intelligently opens either - `*in*` for read-mode files or `*out*` for write-mode files. + :lpy:var:`*in*` for read-mode files or :lpy:var:`*out*` for write-mode files. - This function also supports the `bufsize`, `encoding`, and `errors` options of - `open`." + This function also supports the ``bufsize``, ``encoding``, and ``errors`` options + of ``open``." ([mode] (argparse/FileType mode)) ([mode & {:as opts}] @@ -476,20 +483,38 @@ (apply-kw argparse/ArgumentParser)) (setup-parser parser-or-config)))) +(defn ^:private args->map + "Transform an ``argparse.Namespace`` into a map." + [args] + (->> args + (python/vars) + (.items) + (into {} (map (fn [[k v]] [(keyword (demunge k)) v]))))) + (defn parse-args "Parse command line arguments using the supplied parser or CLI tools configuration - map. If no `args` are given, parse the arguments from Python's `sys/argv`." + map. If no ``args`` are given, parse the arguments from :lpy:var:`*command-line-args*`." ([parser-or-config] - (parse-args parser-or-config sys/argv)) + (parse-args parser-or-config *command-line-args*)) ([parser-or-config args] (-> (cli-parser parser-or-config) (.parse-args args) - (python/vars) - (.items) - (->> (reduce (fn [m [k v]] - (assoc! m (keyword (demunge k)) v)) - (transient {}))) - (persistent!)))) + (args->map)))) + +(defn parse-known-args + "Parse known command line arguments using the supplied parser or CLI tools + configuration map. + + Returns a vector of ``[parsed-args remainder]`` where ``parsed-args`` is a map of + parsed arguments and ``remainder`` is a vector of unrecognized arguments. + + If no ``args`` are given, parse the arguments from :lpy:var:`*command-line-args*`." + ([parser-or-config] + (parse-known-args parser-or-config *command-line-args*)) + ([parser-or-config args] + (let [[parsed remainder] (-> (cli-parser parser-or-config) + (.parse-known-args args))] + [(args->map parsed) (vec remainder)]))) (defn ^:private execute-handler "Execute the handler defined in the arguments. @@ -500,10 +525,10 @@ (defn handle-args "Parse command line arguments using the supplied parser or CLI tools configuration - map (as by `parse-args`) and execute any command or sub-command handlers for the + map (as by :lpy:fn:`parse-args`) and execute any command or sub-command handlers for the parsed arguments. - If no `args` are given, parse the arguments from Python's `sys/argv`." + If no ``args`` are given, parse the arguments from :lpy:var:`*command-line-args*`." ([parser-or-config] (-> (cli-parser parser-or-config) (parse-args) @@ -512,10 +537,3 @@ (->(cli-parser parser-or-config) (parse-args args) (execute-handler)))) - -(defmacro run-cli - "Create a new command-line parser using the configuration `config` and run it - automatically if this namespace is called as the Python `__main__`." - [config] - `(when (= __name__ "__main__") - (handle-args ~config))) diff --git a/src/basilisp/lang/runtime.py b/src/basilisp/lang/runtime.py index d0a20d1a2..6331522a7 100644 --- a/src/basilisp/lang/runtime.py +++ b/src/basilisp/lang/runtime.py @@ -82,6 +82,7 @@ # Public basilisp.core symbol names COMPILER_OPTIONS_VAR_NAME = "*compiler-options*" +COMMAND_LINE_ARGS_VAR_NAME = "*command-line-args*" DEFAULT_READER_FEATURES_VAR_NAME = "*default-reader-features*" GENERATED_PYTHON_VAR_NAME = "*generated-python*" PRINT_GENERATED_PY_VAR_NAME = "*print-generated-python*" @@ -2080,6 +2081,22 @@ def in_ns(s: sym.Symbol): dynamic=True, ) + # Dynamic Var containing command line arguments passed via `basilisp run` + Var.intern( + CORE_NS_SYM, + sym.symbol(COMMAND_LINE_ARGS_VAR_NAME), + None, + dynamic=True, + meta=lmap.map( + { + _DOC_META_KEY: ( + "The set of all currently supported " + ":ref:`reader features `." + ) + } + ), + ) + # Dynamic Var for introspecting the default reader featureset Var.intern( CORE_NS_SYM, diff --git a/tests/basilisp/contrib/test_cli_tools.lpy b/tests/basilisp/contrib/test_cli_tools.lpy index 1636d6ba9..2f5076bfd 100644 --- a/tests/basilisp/contrib/test_cli_tools.lpy +++ b/tests/basilisp/contrib/test_cli_tools.lpy @@ -1,7 +1,8 @@ (ns tests.basilisp.contrib.test-cli-tools (:require [basilisp.contrib.cli-tools :as cli] - [basilisp.test :refer [deftest are is testing]])) + [basilisp.test :refer [deftest are is testing]]) + (:import argparse)) (def ^:private base-parser-config {:command "command" @@ -56,45 +57,68 @@ (deftest cli-parser (let [parser (cli/cli-parser - {:command "basilisp" - :description "Basilisp is a Lisp dialect inspired by Clojure targeting Python 3" - :arguments [] - :commands [{:command "run" - :description "Run a Basilisp script from a file or run Basilisp code directly." - :help "run a Basilisp script or code" - :handler identity - :arguments [{:name "file-or-code" - :help "the filename or, if using -c the code, to execute"} - {:flags ["--in-ns"] - :help "namespace to run the code in" - :default "basilisp.user"} - {:flags ["-c" "--code"] - :help "if provided, treat argument as a string of code" - :default false - :update-fn (constantly true)} + {:command "basilisp" + :description "Basilisp is a Lisp dialect inspired by Clojure targeting Python 3" + :exit-on-error false + :arguments [] + :commands [{:command "run" + :description "Run a Basilisp script from a file or run Basilisp code directly." + :help "run a Basilisp script or code" + :handler identity + :arguments [{:name "file-or-code" + :help "the filename or, if using -c the code, to execute"} + {:flags ["--in-ns"] + :help "namespace to run the code in" + :default "basilisp.user"} + {:flags ["-c" "--code"] + :help "if provided, treat argument as a string of code" + :default false + :update-fn (constantly true)} + {:flags ["--warn-on-shadowed-name"] + :help "if provided, emit warnings if a local name is shadowed by another local name" + :env "BASILISP_WARN_ON_SHADOWED_NAME" + :group "compiler flags"} + {:flags ["--warn-on-shadowed-var"] + :help "if provided, emit warnings if a Var name is shadowed by a local name" + :env "BASILISP_WARN_ON_SHADOWED_VAR" + :group "compiler flags"}]} + {:command "run-all" + :description "Run multiple Basilisp scripts in parallel." + :help "run multiple Basilisp scripts in parallel" + :exit-on-error false + :handler identity + :arguments [{:name "files" + :help "the filename or, if using -c the code, to execute" + :nargs "+"} + {:flags ["-p" "--parallel"] + :help "if provided, number of parallel workres" + :default 1 + :validate [pos? "must be positive"] + :parse-fn python/int}]}]})] + (are [args] (thrown? argparse/ArgumentError (parse-args parser args)) + ["run-all" "-p" "-1" "script.lpy"] + ["run-all" "-p" "hi" "script.lpy"]) - {:flags ["--warn-on-shadowed-name"] - :help "if provided, emit warnings if a local name is shadowed by another local name" - :env "BASILISP_WARN_ON_SHADOWED_NAME" - :group "compiler flags"} - {:flags ["--warn-on-shadowed-var"] - :help "if provided, emit warnings if a Var name is shadowed by a local name" - :env "BASILISP_WARN_ON_SHADOWED_VAR" - :group "compiler flags"}]}]})] (are [args ret] (= ret (dissoc (parse-args parser args) :handler)) - [] {} - ["run" "--in-ns" "basilisp.cli-tools" "cli_tools.lpy"] {:file-or-code "cli_tools.lpy" - :code false - :in-ns "basilisp.cli-tools" - :warn-on-shadowed-var nil - :warn-on-shadowed-name nil} - ["run" "--warn-on-shadowed-name" "-c" "(identity 1)"] {:file-or-code "(identity 1)" - :code true - :in-ns "basilisp.user" - :warn-on-shadowed-var nil - :warn-on-shadowed-name true} - ["run" "--code" "(identity 1)"] {:file-or-code "(identity 1)" - :code true - :in-ns "basilisp.user" - :warn-on-shadowed-var nil - :warn-on-shadowed-name nil}))) + [] {} + + ["run" "--in-ns" "basilisp.contrib.cli-tools" "cli_tools.lpy"] {:file-or-code "cli_tools.lpy" + :code false + :in-ns "basilisp.contrib.cli-tools" + :warn-on-shadowed-var nil + :warn-on-shadowed-name nil} + ["run" "--warn-on-shadowed-name" "-c" "(identity 1)"] {:file-or-code "(identity 1)" + :code true + :in-ns "basilisp.user" + :warn-on-shadowed-var nil + :warn-on-shadowed-name true} + ["run" "--code" "(identity 1)"] {:file-or-code "(identity 1)" + :code true + :in-ns "basilisp.user" + :warn-on-shadowed-var nil + :warn-on-shadowed-name nil} + + ["run-all" "script.lpy"] {:files ["script.lpy"] + :parallel 1} + ["run-all" "-p" "3" "script1.lpy", "script2.lpy" "script3.lpy"] {:files ["script1.lpy", "script2.lpy" "script3.lpy"] + :parallel 3}))) From 06c5db7499a6b4432f246aea3c754809acc859a7 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Mon, 1 Jan 2024 16:09:10 -0500 Subject: [PATCH 35/39] Ya --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c10749398..ca8e68670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * Added support for passing through `:tag` metadata to the generated Python AST (#354) * Added CLI argument parser in `basilisp.contrib.cli-tools` namespace (#535) + * Added support for calling symbols as functions on maps and sets (#775) ### Changed * Optimize calls to Python's `operator` module into their corresponding native operators (#754) * Allow vars to be callable to adhere to Clojure conventions (#767) - * Support symbols as fns for sets and maps (#775) ### Fixed * Fix issue with `(count nil)` throwing an exception (#759) From e60a2255ee2e356612e48984939800ef2640dc0e Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Mon, 1 Jan 2024 16:13:49 -0500 Subject: [PATCH 36/39] Docstring --- src/basilisp/lang/runtime.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/basilisp/lang/runtime.py b/src/basilisp/lang/runtime.py index f7efa3f47..63a95b7f1 100644 --- a/src/basilisp/lang/runtime.py +++ b/src/basilisp/lang/runtime.py @@ -2093,8 +2093,12 @@ def in_ns(s: sym.Symbol): meta=lmap.map( { _DOC_META_KEY: ( - "The set of all currently supported " - ":ref:`reader features `." + "A vector of command line arguments if this process was started " + "with command line arguments as by ``basilisp run {file_or_code}`` " + "or ``nil`` otherwise.\n\n" + "Note that this value will differ from ``sys.argv`` since it will " + "not include the coommand line arguments consumed by Basilisp's " + "own CLI." ) } ), From 6bcb25217ba33fd47f6177696c564c250c48eb92 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Mon, 1 Jan 2024 19:06:08 -0500 Subject: [PATCH 37/39] More test --- tests/basilisp/contrib/test_cli_tools.lpy | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/basilisp/contrib/test_cli_tools.lpy b/tests/basilisp/contrib/test_cli_tools.lpy index 2f5076bfd..c339cae63 100644 --- a/tests/basilisp/contrib/test_cli_tools.lpy +++ b/tests/basilisp/contrib/test_cli_tools.lpy @@ -94,7 +94,11 @@ :help "if provided, number of parallel workres" :default 1 :validate [pos? "must be positive"] - :parse-fn python/int}]}]})] + :parse-fn python/int} + {:flags ["-v" "--verbose"] + :help "increase verbosity level; may be specified multiple times" + :default 1 + :update-fn inc}]}]})] (are [args] (thrown? argparse/ArgumentError (parse-args parser args)) ["run-all" "-p" "-1" "script.lpy"] ["run-all" "-p" "hi" "script.lpy"]) @@ -120,5 +124,8 @@ ["run-all" "script.lpy"] {:files ["script.lpy"] :parallel 1} + ["run-all" "-vvv" "script.lpy"] {:files ["script.lpy"] + :parallel 1 + :verboose 4} ["run-all" "-p" "3" "script1.lpy", "script2.lpy" "script3.lpy"] {:files ["script1.lpy", "script2.lpy" "script3.lpy"] :parallel 3}))) From 0493c60d24bdb998488a7b97f7cd20e51ded86ca Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Mon, 1 Jan 2024 21:13:58 -0500 Subject: [PATCH 38/39] The goods --- src/basilisp/contrib/cli_tools.lpy | 38 ++++++++++++++--------- tests/basilisp/contrib/test_cli_tools.lpy | 31 +++++++++++++++--- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/basilisp/contrib/cli_tools.lpy b/src/basilisp/contrib/cli_tools.lpy index 0059de2d7..e6176ac82 100644 --- a/src/basilisp/contrib/cli_tools.lpy +++ b/src/basilisp/contrib/cli_tools.lpy @@ -134,8 +134,9 @@ (reify ^:abstract argparse/Action (^{:kwargs :collect} __call__ [self _ namespace values _ _] - (-> (wrapped-namespace namespace) - (assoc-fn dest values))) + (let [lisp-val (py->lisp values)] + (-> (wrapped-namespace namespace) + (assoc-fn dest lisp-val)))) (__getattr__ [self name] (let [k (keyword (demunge name))] (if (contains? kwargs k) @@ -179,13 +180,19 @@ (cli-tools-assoc-action (or assoc-fn assoc)))) (defn ^:private validate-argument - [{:keys [name flags nargs assoc-fn update-fn] :as argument}] + [{:keys [name flags dest nargs assoc-fn update-fn] :as argument}] (when (and name flags) (throw (ex-info (str "Arguments may either be positional (via :name) or " "optional (via :flags), not both") {:name name :flags flags}))) + (when (and name dest) + (throw + (ex-info (str "Arguments may either provide a :name or :dest, " + "not both") + {:name name + :dest dest}))) (when update-fn (when assoc-fn (throw @@ -222,18 +229,19 @@ validate-fn) ;; Prepare the keyword arguments for ArgumentParser.add_argument - kwargs {:default (or (some-> (:env argument) (os/getenv)) - (:default argument)) - :const true - :action (cli-tools-action argument) - :nargs (if-let [nargs (:nargs argument)] - (cond-> nargs (keyword? nargs) (name)) - (if (:update-fn argument) 0 "?")) - :type type-fn - :metavar (when-let [metavar (:metavar argument)] - (cond-> metavar - (vector? metavar) (python/tuple))) - :help (:help argument)} + kwargs (cond-> {:default (or (some-> (:env argument) (os/getenv)) + (:default argument)) + :const true + :action (cli-tools-action argument) + :nargs (if-let [nargs (:nargs argument)] + (cond-> nargs (keyword? nargs) (name)) + (if (:update-fn argument) 0 "?")) + :type type-fn + :metavar (when-let [metavar (:metavar argument)] + (cond-> metavar + (vector? metavar) (python/tuple))) + :help (:help argument)} + (:dest argument) (assoc :dest (:dest argument))) ;; Python's ArgumentParser.add_argument method takes variadic positional ;; arguments _and_ keyword arguments, which is a challenging combination diff --git a/tests/basilisp/contrib/test_cli_tools.lpy b/tests/basilisp/contrib/test_cli_tools.lpy index c339cae63..efeb706de 100644 --- a/tests/basilisp/contrib/test_cli_tools.lpy +++ b/tests/basilisp/contrib/test_cli_tools.lpy @@ -35,6 +35,13 @@ (->> (update base-parser-config :arguments conj)) (cli/cli-parser))))) + (testing "disallow name and dest" + (is (thrown? basilisp.lang.exception/ExceptionInfo + (-> base-arg-config + (assoc :dest "dest") + (->> (update base-parser-config :arguments conj)) + (cli/cli-parser))))) + (testing "disallow assoc-fn and update-fn" (is (thrown? basilisp.lang.exception/ExceptionInfo (-> base-arg-config @@ -61,7 +68,18 @@ :description "Basilisp is a Lisp dialect inspired by Clojure targeting Python 3" :exit-on-error false :arguments [] - :commands [{:command "run" + :commands [{:command "compile" + :description "Run multiple Basilisp scripts in parallel." + :help "run multiple Basilisp scripts in parallel" + :exit-on-error false + :handler identity + :arguments [{:dest "optimize" + :flags ["-o"] + :help "set the optimization level" + :default "standard" + :parse-fn keyword + :validate [#{:debug :standard :optimized} "must be one of: :debug, :standard, :optimized"]}]} + {:command "run" :description "Run a Basilisp script from a file or run Basilisp code directly." :help "run a Basilisp script or code" :handler identity @@ -100,12 +118,15 @@ :default 1 :update-fn inc}]}]})] (are [args] (thrown? argparse/ArgumentError (parse-args parser args)) + ["compile" "-o" "kinda"] ["run-all" "-p" "-1" "script.lpy"] ["run-all" "-p" "hi" "script.lpy"]) (are [args ret] (= ret (dissoc (parse-args parser args) :handler)) [] {} + ["compile" "-o" "debug"] {:optimize :debug} + ["run" "--in-ns" "basilisp.contrib.cli-tools" "cli_tools.lpy"] {:file-or-code "cli_tools.lpy" :code false :in-ns "basilisp.contrib.cli-tools" @@ -123,9 +144,11 @@ :warn-on-shadowed-name nil} ["run-all" "script.lpy"] {:files ["script.lpy"] - :parallel 1} + :parallel 1 + :verbose 1} ["run-all" "-vvv" "script.lpy"] {:files ["script.lpy"] :parallel 1 - :verboose 4} + :verbose 4} ["run-all" "-p" "3" "script1.lpy", "script2.lpy" "script3.lpy"] {:files ["script1.lpy", "script2.lpy" "script3.lpy"] - :parallel 3}))) + :parallel 3 + :verbose 1}))) From 83e6717673f8633bbc6b6cfad8408cf0a45dee26 Mon Sep 17 00:00:00 2001 From: Christopher Rink Date: Mon, 1 Jan 2024 21:29:04 -0500 Subject: [PATCH 39/39] Test fix --- tests/basilisp/contrib/nrepl_server_test.lpy | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/basilisp/contrib/nrepl_server_test.lpy b/tests/basilisp/contrib/nrepl_server_test.lpy index 870c115a0..0f2094a28 100644 --- a/tests/basilisp/contrib/nrepl_server_test.lpy +++ b/tests/basilisp/contrib/nrepl_server_test.lpy @@ -165,7 +165,8 @@ (is (= {:id @id* :status ["done"] :completions [{:candidate "apply" :type "function" :ns "basilisp.core"} {:candidate "apply-kw" :type "function" :ns "basilisp.core"} - {:candidate "apply-method" :type "macro" :ns "basilisp.core"}]} + {:candidate "apply-method" :type "macro" :ns "basilisp.core"} + {:candidate "apply-method-kw" :type "macro" :ns "basilisp.core"}]} (client-recv! client))) (client-send! client {:id (id-inc!) :op "complete" :ns "user" :symbol "clojure.string/blank?"}) (is (= {:id @id* :status ["done"]