Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

rewrite to work correctly with booleans and trailing arguments, remov…

…ed grouping functionality
  • Loading branch information...
commit 439917eda8899f90ad75cc92fe37e966420df043 1 parent e76ae71
@gar3thjon3s gar3thjon3s authored
View
133 README.md
@@ -1,67 +1,112 @@
# tools.cli
-tools.cli is a command line argument parser, with the added bonus of
-nested groups of arguments.
+tools.cli is a command line argument parser for Clojure.
-## Usage
-
-Example:
+## An Example
(cli args
- (required ["-p" "--port" "Listen on this port"] #(Integer. %))
- (optional ["--host" "The hostname" :default "localhost"])
- (optional ["--verbose" :default true])
- (optional ["--log-directory" :default "/some/path"])
- (group "--server"
- (optional ["-n" "--name"])
- (optional ["-p" "--port"] #(Integer. %))
- (group "--paths"
- (optional ["--inbound" :default "/tmp/inbound"])
- (optional ["--outbound" :default "/tmp/outbound"]))))
-
+ ["-p" "--port" "Listen on this port" :required true :parse-fn #(Integer. %)]
+ ["-t" "--host" "The hostname" :default "localhost"]
+ ["-v" "--[no-]verbose" :default true]
+ ["-l" "--log-directory" :default "/some/path"])
+
with args of:
- '("-p" "8080"
- "--no-verbose"
- "--log-directory" "/tmp"
- "--server--name" "localhost"
- "--server--port" "9090"
- "--server--paths--inbound" "/dev/null")
+ ["-p" "8080"
+ "--no-verbose"
+ "--log-directory" "/tmp"]
will produce a clojure map with the names picked out for you as keywords:
{:port 8080
:host "localhost"
:verbose false
- :log-directory "/tmp"
- :server {:name "localhost"
- :port 9090
- :paths {:inbound "/dev/null"
- :outbound "/tmp/outbound"}}}
+ :log-directory "/tmp"}
-A flag of -h or --help is provided which will currently give a
-documentation string:
+A flag of -h or --help is provided which will print a documentation
+string to STDOUT and call System/exit:
Usage:
Switches Default Required Desc
-------- ------- -------- ----
-p, --port Yes Listen on this port
- --host localhost No The hostname
- --verbose true No
- --log-directory /some/path No /some/path
- --server-n, --server--name No
- --server-p, --server--port No
- --server--paths--inbound /tmp/inbound No /tmp/inbound
- --server--paths--outbound /tmp/outbound No /tmp/outbound
-
-Required parameters will halt program execution if not provided,
-optionals will not. Defaults can be provided as shown above. Errors
-caused by parsing functions (such as #(Integer. %) above) will halt
-program execution. Doc strings are provided as shown above on port and
-host.
-
-See the tests for more example usage.
+ -h, --host localhost No The hostname
+ -v, --no-verbose --verbose true No
+ -l, --log-directory /some/path No
+
+## Options
+
+An argument is specified by providing a vector of information:
+
+Switches should be provided first, from least to most specific. The
+last switch you provide will be used as the name for the argument in
+the resulting hash-map. The following:
+
+ ["-p" "--port"]
+
+defines an argument with two possible switches, the name of which will
+be :port in the resulting hash-map.
+
+Next is an optional doc string:
+
+ ["-p" "--port" "The port to listen on"]
+
+This will be printed in the 'Desc' column if the -h or --help flags
+are provided.
+
+Following that are optional parameters, provided in key-value pairs:
+
+ ["-p" "--port" "The port to listen on" :default 8080 :parse-fn #(Integer. %) :required true]
+
+These should be self-explanatory. The defaults if not provided are as follows:
+
+ {:default nil
+ :parse-fn identity
+ :required false}
+
+### Boolean Flags
+
+Flags are indicated either through naming convention:
+
+ ["-v" "--[no-]verbose" "Be chatty"]
+
+(note the [no-] in the argument name).
+
+Or you can explicitly mark them as flags:
+
+ ["-v" "--verbose" "Be chatty" :flag true]
+
+Either way, when providing them on the command line, using the name
+itself will set to true, and using the name prefixed with 'no-' will
+set the argument to false:
+
+ (cli ["-v"]
+ ["-v" "--[no-]verbose"])
+
+ => {:verbose true}
+
+ (cli ["--no-verbose"]
+ ["-v" "--[no-]verbose"])
+
+ => {:verbose false}
+
+Note: there is no short-form to set the flag to false (-no-v will not
+work!).
+
+## Trailing Arguments
+
+After all of your arguments have been parsed, any trailing arguments
+given will be available to your program under a key called :args in
+the resulting hash-map:
+
+ (cli ["--port" "9999" "some" "extra" "arguments"]
+ ["--port" :parse-fn #(Integer. %)])
+
+ => {:port 9999, :args ["some" "extra" "arguments"]}
+
+This allows you to deal with parameters such as filenames which are
+commonly provided at the end of an argument list.
## License
View
183 src/main/clojure/clojure/tools/cli.clj
@@ -4,10 +4,10 @@
[clojure.pprint :only (pprint cl-format)])
(:refer-clojure :exclude [replace]))
-(defn build-doc [{:keys [switches docs options]}]
+(defn build-doc [{:keys [switches docs default required]}]
[(apply str (interpose ", " switches))
- (or (str (options :default)) "")
- (if (options :required) "Yes" "No")
+ (or (str default) "")
+ (if required "Yes" "No")
(or docs "")])
(defn show-help [specs]
@@ -25,16 +25,12 @@
(cl-format true "~{ ~vA ~vA ~vA ~vA ~}" v)
(prn))))
-(defn print-and-fail [msg]
- (println msg)
- (System/exit 1))
-
(defn help-and-quit [specs]
(show-help specs)
(System/exit 0))
(defn name-for [k]
- (replace k #"^--no-|^--|^-" ""))
+ (replace k #"^--no-|^--\[no-\]|^--|^-" ""))
(defn flag-for [v]
(not (.startsWith v "--no-")))
@@ -42,98 +38,83 @@
(defn opt? [x]
(.startsWith x "-"))
-(defn strip-parents [alias]
- (last (.split alias "--")))
-
-(defn path-for [alias]
- (map keyword (.split alias "--")))
-
-(defn parse-args [args]
- (into {}
- (map (fn [[k v]]
- (if (and (opt? k) (or (nil? v) (opt? v)))
- [(name-for k) (flag-for k)]
- [(name-for k) v]))
- (filter (fn [[k v]] (opt? k))
- (partition-all 2 1 args)))))
-
-(defn parse-spec [spec args]
- (let [{:keys [parse-fn aliases options path]} spec
- raw (->> (map #(args %) aliases)
- (remove nil?)
- (first))
- raw (if (nil? raw)
- (:default options)
- raw)]
- (if (and (nil? raw)
- (:required options))
- (print-and-fail (str (last aliases) " is a required parameter"))
- (try
- [path (parse-fn raw)]
- (catch Exception _
- (print-and-fail (str "could not parse " (last aliases) " with value of " raw)))))))
-
-(defn optional
- "Generates a function for parsing optional arguments. Params is a
- vector containing string aliases for the argument (from less to more
- specific), a doc string, and optionally a default value prefixed
- by :default.
-
- Parse-fn is an optional parsing function for converting
- a string value into something more useful.
-
- Example:
-
- (optional [\"-p\" \"--port\"
- \"Listen for connections on this port\"
- :default 8080]
- #(Integer. %))"
- [params & [parse-fn]]
- (fn [parent args]
- (let [parse-fn (or parse-fn identity)
- options (apply hash-map (drop-while string? params))
- switches (->> (take-while #(and (string? %) (opt? %)) params)
- (map #(str parent %)))
- aliases (map name-for switches)
- docs (first (filter #(and (string? %) (not (opt? %))) params))
- name (or (options :name)
- (strip-parents (last aliases)))
- path (path-for (last aliases))]
- {:parse-fn parse-fn
- :options options
- :aliases aliases
- :switches switches
- :docs docs
- :name name
- :path path})))
-
-(defn required
- "Generates a function for parsing required arguments. Takes same
- parameters as 'optional'. Not providing this argument to clargon
- will cause an error to be printed and program execution to be
- halted."
- [params & [parse-fn]]
- (optional (into params [:required true]) parse-fn))
-
-(defn group
- "Generates a function for parsing a named group of 'optional' and
- 'required' arguments."
- [name & spec-fns]
- (fn [parent args]
- (let [full-name (if (empty? parent)
- name
- (str parent name))]
- (map #(% full-name args) spec-fns))))
+(defn flag? [x]
+ (.startsWith x "--[no-]"))
+
+(defn spec-for
+ [arg specs]
+ (first (filter #(.contains (% :switches) arg) specs)))
+
+(defn default-values-for
+ [specs]
+ (into {:args []} (for [s specs] [(s :name) (s :default)])))
+
+(defn apply-specs
+ [specs args]
+ (loop [result (default-values-for specs)
+ args args]
+ (if-not (seq args)
+ result
+ (let [opt (first args)
+ spec (spec-for opt specs)]
+ (cond
+ (and (opt? opt) (nil? spec))
+ (throw (Exception. (str "'" opt "' is not a valid argument")))
+
+ (and (opt? opt) (spec :flag))
+ (recur (assoc result (spec :name) (flag-for opt))
+ (rest args))
+
+ (opt? opt)
+ (recur (assoc result (spec :name) ((spec :parse-fn) (second args)))
+ (drop 2 args))
+
+ :default
+ (recur (update-in result [:args] conj (first args)) (rest args)))))))
+
+(defn switches-for
+ [switches flag]
+ (-> (for [s switches]
+ (cond
+ (and flag (flag? s)) [(replace s #"\[no-\]" "no-") (replace s #"\[no-\]" "")]
+ (and flag (.startsWith s "--")) [(replace s #"--" "--no-") s]
+ :default [s]))
+ flatten))
+
+(defn generate-spec
+ [raw-spec]
+ (let [[switches raw-spec] (split-with #(and (string? %) (opt? %)) raw-spec)
+ [docs raw-spec] (split-with string? raw-spec)
+ options (apply hash-map raw-spec)
+ aliases (map name-for switches)
+ flag (or (flag? (last switches)) (options :flag))]
+ (merge {:switches (switches-for switches flag)
+ :docs (first docs)
+ :aliases (set aliases)
+ :name (keyword (last aliases))
+ :parse-fn identity
+ :default (if flag false nil)
+ :required false
+ :flag flag}
+ options)))
+
+(defn wants-help?
+ [args]
+ (some #(or (= % "-h") (= % "--help")) args))
+
+(defn ensure-required-provided
+ [m specs]
+ (doseq [s specs
+ :when (s :required)]
+ (when-not (m (s :name))
+ (throw (Exception. (str (s :name) " is a required argument"))))))
(defn cli
- "Takes a list of args from the command line and applies the spec-fns
- to generate a map of options.
-
- Spec-fns are calls to 'optional', 'required', and 'group'."
- [args & spec-fns]
- (let [args (parse-args args)
- specs (flatten (map #(% "" args) spec-fns))]
- (if (some #(contains? #{"h" "help"} %) (keys args))
- (help-and-quit specs)
- (reduce (fn [h [path value]] (assoc-in h path value))
- {} (map #(parse-spec % args) specs)))))
+ [args & specs]
+ (let [specs (map generate-spec specs)]
+ (when (wants-help? args)
+ (help-and-quit specs))
+ (let [result (apply-specs specs args)]
+ (ensure-required-provided result specs)
+ result)))
+
View
129 src/test/clojure/clojure/tools/cli_test.clj
@@ -4,87 +4,100 @@
(testing "syntax"
(deftest should-handle-simple-strings
- (is (= {:host "localhost"}
- (cli '("--host" "localhost") (optional ["--host"])))))
+ (is (= {:host "localhost"
+ :args []}
+ (cli ["--host" "localhost"]
+ ["--host"]))))
(testing "booleans"
(deftest should-handle-trues
- (is (= {:verbose true}
- (cli '("--verbose") (optional ["--verbose"])))))
-
+ (is (= {:verbose true
+ :args []}
+ (cli ["--verbose"]
+ ["--[no-]verbose"]))))
(deftest should-handle-falses
- (is (= {:verbose false}
- (cli '("--no-verbose") (optional ["--verbose"]))))))
+ (is (= {:verbose false
+ :args []}
+ (cli ["--no-verbose"]
+ ["--[no-]verbose"]))))
+
+ (testing "explicit syntax"
+ (is (= {:verbose true
+ :args []}
+ (cli ["--verbose"]
+ ["--verbose" :flag true])))
+ (is (= {:verbose false
+ :args []}
+ (cli ["--no-verbose"]
+ ["--verbose" :flag true])))))
(testing "default values"
(deftest should-default-when-no-value
- (is (= {:server "10.0.1.10"}
- (cli '() (optional ["--server" :default "10.0.1.10"])))))
+ (is (= {:server "10.0.1.10"
+ :args []}
+ (cli []
+ ["--server" :default "10.0.1.10"]))))
(deftest should-override-when-supplied
- (is (= {:server "127.0.0.1"}
- (cli '("--server" "127.0.0.1") (optional ["--server" :default "10.0.1.10"]))))))
+ (is (= {:server "127.0.0.1"
+ :args []}
+ (cli ["--server" "127.0.0.1"]
+ ["--server" :default "10.0.1.10"])))))
(deftest should-apply-parse-fn
- (is (= {:names ["john" "jeff" "steve"]}
- (cli '("--names" "john,jeff,steve")
- (optional ["--names"] #(vec (.split % ",")))))))
+ (is (= {:names ["john" "jeff" "steve"]
+ :args []}
+ (cli ["--names" "john,jeff,steve"]
+ ["--names" :parse-fn #(vec (.split % ","))]))))
(testing "aliases"
(deftest should-support-multiple-aliases
- (is (= {:server "localhost"}
- (cli '("-s" "localhost")
- (optional ["-s" "--server"])))))
+ (is (= {:server "localhost"
+ :args []}
+ (cli ["-s" "localhost"]
+ ["-s" "--server"]))))
(deftest should-use-last-alias-provided-as-name-in-map
- (is (= {:sizzle "localhost"}
- (cli '("-s" "localhost")
- (optional ["-s" "--server" "--sizzle"]))))))
+ (is (= {:server "localhost"
+ :args []}
+ (cli ["-s" "localhost"]
+ ["-s" "--server"])))))
(testing "required"
(deftest should-succeed-when-provided
- (cli '("--server" "localhost")
- (required ["--server"]))))
+ (cli ["--server" "localhost"]
+ ["--server" :required true]))
+
+ (deftest should-raise-when-missing
+ (is (thrown-with-msg? Exception #"server is a required argument"
+ (cli []
+ ["--server" :required true])))))
- (testing "grouped parameters"
- (deftest should-support-groups
- (is (= {:server {:name "localhost"
- :port 9090}}
- (cli '("--server--name" "localhost" "--server--port" "9090")
- (group "--server"
- (optional ["--name"])
- (optional ["--port"] #(Integer. %)))))))
+ (testing "extra arguments"
+ (deftest should-provide-access-to-trailing-args
+ (is (= {:foo "bar"
+ :args ["a" "b" "c"]}
+ (cli ["--foo" "bar" "a" "b" "c"]
+ ["-f" "--foo"]))))
- (deftest should-support-nested-groups
- (is (= {:servers {:client {:host {:name "localhost" :port 1234}}}}
- (cli '("--servers--client--host--name" "localhost")
- (group "--servers"
- (group "--client"
- (group "--host"
- (optional ["--name"])
- (optional ["--port" :default 1234]))))))))))
+ (deftest should-work-with-trailing-boolean-args
+ (is (= {:verbose false
+ :args ["some-file"]}
+ (cli ["--no-verbose" "some-file"]
+ ["--[no-]verbose"]))))))
(deftest all-together-now
(is (= {:port 8080
:host "localhost"
:verbose false
:log-directory "/tmp"
- :server {:name "localhost"
- :port 9090
- :paths {:inbound "/dev/null"
- :outbound "/tmp/outbound"}}}
- (cli '("-p" "8080"
- "--no-verbose"
- "--log-directory" "/tmp"
- "--server--name" "localhost"
- "--server--port" "9090"
- "--server--paths--inbound" "/dev/null")
- (required ["-p" "--port"] #(Integer. %))
- (optional ["--host" :default "localhost"])
- (optional ["--verbose" :default true])
- (optional ["--log-directory" :default "/some/path"])
- (group "--server"
- (optional ["-n" "--name"])
- (optional ["-p" "--port"] #(Integer. %))
- (group "--paths"
- (optional ["--inbound" :default "/tmp/inbound"])
- (optional ["--outbound" :default "/tmp/outbound"])))))))
+ :server "localhost"
+ :args []}
+ (cli ["-p" "8080"
+ "--no-verbose"
+ "--log-directory" "/tmp"
+ "--server" "localhost"]
+ ["-p" "--port" :parse-fn #(Integer. %)]
+ ["--host" :default "localhost"]
+ ["--[no-]verbose" :default true]
+ ["--log-directory" :default "/some/path"]
+ ["--server"]))))

12 comments on commit 439917e

@michaelklishin

This change breaks existing applications running 0.1.0. Would it be possible to articulate breaking changes like this upfront on the mailing list?

@abedra
Collaborator

That's why there are maven releases. If you continue to use 0.1.0, nothing will break for you. If you want to move to 0.2.0+ you will need to adjust your code. This was discussed at length on the list.

@michaelklishin

@abedra,

Do you mind providing a link for the aforementioned discussion? Searching over http://groups.google.com/group/clojure archives yields nothing. Maybe tools.cli has a separate mailing list?

@abedra
Collaborator
@michaelklishin

Ok, so to join that mailing list people need to request an invitation. That's ok. What is not OK in my opinion is that end users have to subscribe to clojure-dev to be notified of breaking API changes that affect end users. Are my expectations of such changes being announced on the "regular" clojure list unreasonable?

@michaelklishin

Also, the README for this project does not mention clojure-dev and to submit a patch I have to first mail a piece of paper to the other side of the world (hint: it will take many weeks). I really hope Clojure/core will consider accepting scanned Contributor Agreement via email (or heck, fax) and using github pull requests.

@richhickey
Owner

Anyone can monitor the dev lists:

http://groups.google.com/group/clojure-dev/feeds

@michaelklishin

@richhickey it is true but to monitor it, you first have to be aware of it. I see no reason to not announce library releases on the main Clojure mailing list.

@abedra
Collaborator

The announcement has not gone out to the list. I pushed the actual release at 12:30 am est today. There will be a release announcement as soon as gaz can verify that the bits are official on maven central.

@richhickey
Owner

@michaelklishin - chill out, please.

@gar3thjon3s

Apologies for any misunderstandings here, I fully intended to announce the changes on the regular mailing list -- as @abedra intimated I was just waiting for it to actually make it to maven central.

@ath

@abedra Do you do Semantic Versioning for this lib? If yes then please advertise this in the readme file, as suggested on http://semver.org/ — “[…] all you need to do to start using Semantic Versioning is to declare that you are doing so and then follow the rules.”

@michaelklishin It think that this lib is doing Semantic Versioning. Point 5 of version 2.0.0-rc.1 of http://semver.org/ covers this issue: “Major version zero (0.y.z) is for initial development. Anything may change at any time. The public API should not be considered stable.”

Please sign in to comment.
Something went wrong with that request. Please try again.