Problem validating a string option in a commandline tool (tools.cli)


#1

Hi! :slight_smile:

I got some free time between projects and i am starting to learn Clojure.

And for helping in this task, i decided to port a few commandline python scripts that i made a few years ago.

Well, i made this code using the tools.cli library for parsing the commandline:

(ns random-options.core
    (:require [clojure.string :as string]
              [clojure.tools.cli :refer [parse-opts]])
    (:gen-class))  
    
;; --------------------------------------------------------------------------
(def random-options {"option1" :option1 "option2" :option2})

(defn random-options? [x] (contains? random-options x))

# Specific functions for each option
(def random-functions {:option1 #(%)
                        :option2 #(%)})

;; --------------------------------------------------------------------------
(def cli-options
    [["-h" "--help" "Show this screen."]
      [nil "--version" "Show version."]
      ["-d" "--debug" "Debug information."]
      ["-r" "--random OPTION" "Choose random option"
        :default "option1"
        :parse-fn #(random-options %)
        :validate [#(random-options? string/lower-case(%)) 
           "Valid options are option1 | option2"]]])
 
;; --------------------------------------------------------------------------
(defn -main [& args]
  (println (parse-opts args cli-options)))

But when i execute this code with:

$ lein.sh run -r option2
{:options {:random option1}, :arguments [322 chunky], :summary   -h, --help                    Show this screen.
      --version                 Show version.
  -d, --debug                   Debug information.
  -r, --random OPTION  option1  Choose random option,
 :errors [Failed to validate "-r option2": Valid options are option1 | option2]}

It looks that the string argument (“option2” in this example) fails the validation. And i don’t understand why… I have tried to search about this error, because i imagine that must be a typical novice error, but i haven’t luck with that. Somebody can give me a tip about how to fix this code.

Thanks! :slight_smile:


#2

#(random-options? string/lower-case(%))

The above form seems very off. string/lower-case(%) is not the way a Clojure function is invoked.


#3

Hi and welcome @mml :slight_smile:. You’re on the right track there. Let’s take a closer look at the the function you passed as :validate.

#(random-options? string/lower-case(%))

According to the documentation:

    :validate-fn  A vector of functions that receives the parsed option value
                  and returns a falsy value or throws an exception when the
                  value is invalid. The validations are tried in the given
                  order.

So the function above will get a parsed option as its argument. Let’s expand the terse hash-percent syntax to see what’s going on here.

(fn [parsed-option] (random-options? string/lower-case (parsed-option)))

Hmm, something’s not right. In the current shape the function will call random-options? with two arguments. That’s most likely not what we want here.

Instead, we’d probably want to call random-options? with a single argument, which we obtain by calling string/lower-case on parsed-option. Something like:

(fn [parsed-option] (random-options? (string/lower-case parsed-option)))

or a bit terser:

#(random-options? (string/lower-case %))

But that’s still not what we need. Notice that the function above gets a parsed option as its argument. In other words, the argument will be firstly processed by the :parse-fn you specified above.

Your parsing function maps strings to keywords. Given "option1" it returns :option1. As a result, your validating function will get a keyword as its argument. Is that what the validating function expects? :slight_smile:

HTH and good luck debugging. Feel free to follow up if you run into any other problems!


#4

Also, your parse-fn converts the string input to a keyword (either :option1 or :option2) and parsing happens before validation so by the time you call validate-fn, you have keywords, not strings.

You could achieve this more simply with:

:parse-fn keyword ; convert string to keyword
:validate-fn #{:option1 :option2} ; set as predicate: returns truthy if argument is a member of the set

#5

OMG! :sweat_smile:

I hope to get more interesting questions the next time. Thanks guys!!! :smiley:

Great explanation @jan!!! And nice simplification @seancorfield!!! :clap:


#6

Also take a look at cli-matic, I found it much easier to use than tools.cli directly.


#7

I have just discovered clj-matic as well. Have to say that the :ednfile option (and similar ones) are the biggest time savers ever :smile: No more copy and paste yeah!


#8

Sounds like I need to do some enhancements on tools.cli then? :slight_smile:

Feel free to add JIRA tickets for enhancements to tools.cli: https://dev.clojure.org/jira/browse/TCLI


#9

I think that tools.cli is a different thing. It does one thing very well. CLI-matic has a broader scope, and is more opinionated - let’s trade some flexibility for convenience, just plug it in and think about something else.


#10

Thanks for the tip about cli-matic, although after solving my silly bug with your help, everything is coming nice and easy, and tools.cli is working perfectly. Great library Mr. @seancorfield!!! :slight_smile:

For reference, the example code bugfixed is:

(ns random-options.core
  (:require [clojure.string :as string]
            [clojure.tools.cli :refer [parse-opts]])
  (:gen-class))  
  
;; --------------------------------------------------------------------------
;; In the real script, there is a few those maps with lambda 
;; functions, those are used for choosing the quantization
;; algorithm, select endianness, ...
(def random-options {:option1 #(%)
                      :option2 #(%)})

;; --------------------------------------------------------------------------
(def cli-options
  [["-h" "--help" "Show this screen."]
    [nil "--version" "Show version."]
    ["-d" "--debug" "Debug information."]
    ["-r" "--random OPTION" "Choose random option"
      :default "option1"
      :parse-fn keyword
      :validate [#(contains? random-options %) "Valid options are option1 | option2"]]])

;; --------------------------------------------------------------------------
(defn -main [& args]
(println (parse-opts args cli-options)))

Simple and straightforward :slight_smile: