src/therapist

Search:
Group by:

Therapist - for when commands and arguments are getting you down

A simple to use, declarative, type-safe command line parser, with beautiful help messages and clear errors, suitable for simple scripts and complex tools.

Therapist allows you to use a carefully constructed tuple to specify how you want your commandline arguments to be parsed. Each value in the tuple must be set to an Arg, which specifies how that argument will appear, what values it can take and provides a help string for the user.

The constructor for each Arg type takes the form:

# doctest: skip
proc newStringArg*(variants: seq[string], help: string, defaultVal="", choices=newSeq[string](),
                    helpvar="", required=false, optional=false, multi=false, env="")
  • Every argument must be declared with one or more variants. There are three types of argument:
    • Positional Arguments are declared in variants as <value> whose value is determined by the order of arguments provided. They are required unless optional=true
    • Optional Arguments are declared in variants as -o (short form) or --option (long form) which may take an argument or simply be counted. They are optional unless required=true
    • Commands (declared in variants as command) are expected to be entered by the user as written. The remainder of the arguments are parsed by a subparser which may have a different specification to the main parser
  • Options may be interleaved with arguments, so > markup input.txt -o output.html is the same as > markup -o output.html input.txt
  • Options that take a value derive from ValueArg and may be entered as -o <value>, -o:<value> or -o=<value> (similarly for the long form i.e. --option <value> etc). Short options may be repeated, e.g. -vvv or take values without a separator e.g. -o<value>
  • A CountArg is a special type of Arg that counts how many times it is seen, without taking a value (sometimes called a flag).
  • CountArg also allows some special variant formats. If you specify --[no]option, then --option will count upwards (args.count>0) and --nooption will count downwards (args.count<0). Alternatively -y/-n or --yes/--no will count upwards for -y or --yes and downwards for -n or --no. Note that args.seen will return true if args.count!=0.
  • If a command is seen, parsing will switch to that command immediately. So in > pal --verbose push --force, the base parser receives --verbose, and the push command parser receives --force
  • If an argument has been seen arg.seen will return true. The values will also be entered into a values seq, with the most recently seen value stored in value. The number of times the argument has been seen can be found in arg.count
  • If -- is seen, the remainder of the arguments will be taken to be positional arguments, even if they look like options or commands
  • A defaultVal value may be provided in case the argument is not seen. Additionally an env key can be provided (e.g. env=USER). If env is set to a key that is set in the environment, the default value will be set to that value e.g. $USER).
  • Arguments are expected to be seen at most once, unless multi=true
  • If there are only a set number of acceptable values for an argument, they can be listed in choices
  • A helpvar may be provided for use in the autogenerated help (e.g. helpvar="n" would lead to a help message saying --number=<n>)
  • Within the help message, arguments are usually grouped into Commands, Arguments and Options. If you want to group them differently, use the group parameter to define new groups. Groups and arguments will be shown the order that they are appear in the tuple definition.
  • If you want to define a new ValueArg type defineArg is a template that will fill in the boilerplate for you

Argument types provided out of the box

  • ValueArg - base class for arguments that take a value
    • StringArg - expects a string
    • IntArg - expects an integer
    • FloatArg - expects a floating point number
    • BoolArg - expects a boolean (on/off, true/false)
    • FileArg - expects a string argument that must point to an existing file
    • DirArg - expects a string argument that must point to an existing directory
    • PathArg - expects a string that must point to an existing file or directory
  • CountArg - expects no value, simply counts how many times the argument is seen
  • HelpArg - if seen, prints an auto-generated help message
  • MessageArg - if seen, prints a message (e.g. version number)

Creating your own argument type

Creating your own ValueArg is as simple as defining a parse method that turns a string into an appropriate value (or raises a ValueError for invalid input). Suppose we want to create a DateArg type that only accepts ISO-formatted dates:

import therapist
import times

let DEFAULT_DATE = initDateTime(1, mJan, 2000, 0, 0, 0, 0)
proc parseDate(value: string): DateTime = parse(value, "YYYY-MM-dd")
defineArg[DateTime](DateArg, newDateArg, "date", parseDate, DEFAULT_DATE)

Now we can call newDateArg to ask the user to supply a date

Examples

A simple 'Hello world' example:

import therapist

# The parser is specified as a tuple
let spec = (
    # Name is a positional argument, by virtue of being surrounded by < and >
    name: newStringArg(@["<name>"], help="Person to greet"),
    # --times is an optional argument, by virtue of starting with - and/or --
    times: newIntArg(@["-t", "--times"], default=1, help="How many times to greet"),
    # --version will cause 0.1.0 to be printed
    version: newMessageArg(@["--version"], "0.1.0", help="Prints version"),
    # --help will cause a help message to be printed
    help: newHelpArg(@["-h", "--help"], help="Show help message"),
)
# `args` and `command` would normally be picked up from the commandline
spec.parseOrQuit(prolog="Greeter", args="-t 2 World", command="hello")
# If a help message or version was requested or a parse error generated it would be printed
# and then the parser would call `quit`. Getting past `parseOrQuit` implies we're ok.
for i in 1..spec.times.value:
    echo "Hello " & spec.name.value

doAssert spec.name.seen
doAssert spec.name.value == "World"
doAssert spec.times.seen
doAssert spec.times.value == 2

The above parser generates the following help message

Greeter

Usage:
  hello <name>
  hello --version
  hello -h|--help

Arguments:
  <name>               Person to greet

Options:
  -t, --times=<times>  How many times to greet [default: 1]
  --version            Prints version
  -h, --help           Show help message

At the other extreme, you can create complex parsers with subcommands (the example below may be familiar to those who have seen docopt.nim). Note that the help message is slightly different; this is in part because parser itself is stricter. For example, --moored is only valid inside the mine subcommand, and as such, will only appear in the help for that command, shown if you run navel_fate mine --help.

import options
import strutils
import therapist

let prolog = "Navel Fate."

let create = (
      name: newStringArg(@["<name>"], multi=true, help="Name of new ship")
)
let move = (
      name: newStringArg(@["<name>"], help="Name of new ship"),
      x: newIntArg(@["<x>"], help="x grid reference"),
      y: newIntArg(@["<y>"], help="y grid reference"),
      speed: newIntArg(@["--speed"], default=10, help="Speed in knots [default: 10]"),
      help: newHelpArg()
)
let shoot = (
      x: newIntArg(@["<x>"], help="Name of new ship"),
      y: newIntArg(@["<y>"], help="Name of new ship"),
      help: newHelpArg()
)
let state = (
      moored: newCountArg(@["--moored"], help="Moored (anchored) mine"),
      drifting: newCountArg(@["--drifting"], help="Drifting mine"),
)
let mine = (
      action: newStringArg(@["<action>"], choices = @["set", "remove"], help="Action to perform"),
      x: newIntArg(@["<x>"], help="Name of new ship"),
      y: newIntArg(@["<y>"], help="Name of new ship"),
      state: state,
      help: newHelpArg()
)

let ship = (
      create: newCommandArg(@["new"], create, help="Create a new ship"),
      move: newCommandArg(@["move"], move, help="Move a ship"),
      shoot: newCommandArg(@["shoot"], shoot, help="Shoot at another ship"),
      help: newHelpArg()
)

let spec = (
      ship: newCommandArg(@["ship"], ship, help="Ship commands"),
      mine: newCommandArg(@["mine"], mine, help="Mine commands"),
      help: newHelpArg()
)

let (success, message) = spec.parseOrMessage(prolog="Navel Fate.", args="--help", command="navel_fate")

let expected = """
Navel Fate.

Usage:
  navel_fate ship new <name>...
  navel_fate ship move <name> <x> <y>
  navel_fate ship shoot <x> <y>
  navel_fate mine (set|remove) <x> <y>
  navel_fate -h|--help

Commands:
  ship        Ship commands
  mine        Mine commands

Options:
  -h, --help  Show help message""".strip()

doAssert success and message.isSome
doAssert message.get == expected

Many more examples are available in the source code and in the nimdoc for the various functions.

Possible features therapist does not have

In rough order of likelihood of being added:

  • 'Hidden' arguments (so you can have --help and --extended-help)
  • Options for help format from columns (current) to paragraphs
  • Ints and floats being limited to a range rather than a set of discrete values
  • Support for +w and -w to equate to w=true and w=false
  • Integration with bash / fish completion scripts
  • Dependent option requirements i.e. because --optionA appears, --optionB is required
  • Case insensitive matching
  • Partial matches for commands i.e. pal pus is the same as pal push, if that is the only unambiguous match
  • Support for alternate option characters (e.g. /) or different option semantics (e.g. java-style single - -option)

Installation

Clone the repository and then run:

> nimble install

Contributing

The code lives on bitbucket. Pull requests (with tests) and bug reports welcome!

Alternatives and prior art

This is therapist. There are many argument parsers like it, but this one is mine. Which one you prefer is likely a matter of taste. If you want to explore alternatives, you might like to look at:

  • parseopt - for if you like to parse your args as they are flung at you, old school style
  • nim-argparse - looks nice, but heavy use of macros, which makes it a little too magic for my tastes
  • docopt.nim - you get to craft your help message, but how you use the results (and what the spec actually means) has always felt inscrutable to me.

Types

Arg = ref object of RootObj
  count*: int                ## How many times the argument was seen
  
Base class for arguments
ArgError = object of CatchableError
  nil
Base Exception for module
BoolArg {.inject.} = ref object of ValueArg
  value*: T
  values*: seq[T]
  
CommandArg = ref object of Arg
  
CommandArg represents a subcommand, which will be processed with its own parser
CountArg = ref object of Arg
  
Counts the number of times this argument appears
DirArg {.inject.} = ref object of ValueArg
  value*: T
  values*: seq[T]
  
FileArg {.inject.} = ref object of ValueArg
  value*: T
  values*: seq[T]
  
FloatArg = ref object of ValueArg
  value*: float
  values*: seq[float]
  
An argument or option whose value is a float
HelpArg = ref object of CountArg
  
If this argument is provided, a MessageError containing a help message will be raised
IntArg = ref object of ValueArg
  value*: int
  values*: seq[int]
  
An argument or option whose value is an int
MessageArg = ref object of CountArg
  
If this argument is provided, a MessageError containing a message will be raised
MessageError = object of ArgError
  nil
Indicates parsing ended early (e.g. because user asked for help). Expected behaviour is that the exception message will be shown to the user and the program will terminate indicating success
ParseError = object of ArgError
  nil
 Indicates parsing ended early (e.g. because user didn't supply correct options). Expected behaviour is that the exception message will be shown to the user and the program will terminate indicating failure.
PathArg {.inject.} = ref object of ValueArg
  value*: T
  values*: seq[T]
  
PromptArg = ref object of Arg
  
Base class for arguments whose value is read from a prompt not an argument
SpecificationError = object of Defect
  nil
 Indicates an error in the specification. This error is thrown during an attempt to create a parser with an invalid specification and as such indicates a programming error
StringArg = ref object of ValueArg
  value*: string
  values*: seq[string]
  
An argument or option whose value is a string
StringPromptArg = ref object of PromptArg
  value*: string
  values*: seq[string]
  
URLArg {.inject.} = ref object of ValueArg
  value*: T
  values*: seq[T]
  
ValueArg = ref object of Arg
  nil
Base class for arguments that take a value

Procs

proc initArg[A, T](arg: var A; variants: seq[string]; help: string;
                   defaultVal: T; choices: seq[T]; helpVar = ""; group = "";
                   required: bool; optional: bool; multi: bool; env: string)
If you define your own ValueArg type, you can call this function to initialise it. It copies the parameter values to the ValueArg object and initialises the value field with either the value from the env environment key (if supplied and if the key is present in the environment) or defaultVal
proc newBoolArg(variants`gensym131: seq[string]; help`gensym131: string;
                defaultVal`gensym131: T = false;
                choices`gensym131 = newSeq[T](); helpvar`gensym131 = "";
                group`gensym131 = ""; required`gensym131 = false;
                optional`gensym131 = false; multi`gensym131 = false;
                env`gensym131 = ""): BoolArg {....raises: [Exception],
    tags: [ReadEnvEffect, RootEffect], forbids: [].}
Template-defined constructor - see help for newStringArg for the meaning of parameters
proc newCommandArg(variants: seq[string]; specification: tuple; help = "";
                   prolog = ""; epilog = ""; group = ""): CommandArg
proc newCountArg(variants: seq[string]; help: string; default = 0;
                 choices = newSeq(); group = ""; required = false;
                 optional = false; multi = true; env = ""): CountArg {.
    ...raises: [], tags: [], forbids: [].}
A CountArg counts how many times it has been seen
import options

let spec = (
    verbosity: newCountArg(@["-v", "--verbosity"], help="Verbosity")
)
let (success, message) = parseOrMessage(spec, args="-v -v -v", command="hello")
doAssert success and message.isNone
doAssert spec.verbosity.count == 3
proc newDirArg(variants`gensym153: seq[string]; help`gensym153: string;
               defaultVal`gensym153: T = ""; choices`gensym153 = newSeq[T]();
               helpvar`gensym153 = ""; group`gensym153 = "";
               required`gensym153 = false; optional`gensym153 = false;
               multi`gensym153 = false; env`gensym153 = ""): DirArg {.
    ...raises: [Exception], tags: [ReadEnvEffect, RootEffect], forbids: [].}
Template-defined constructor - see help for newStringArg for the meaning of parameters
proc newFileArg(variants`gensym142: seq[string]; help`gensym142: string;
                defaultVal`gensym142: T = ""; choices`gensym142 = newSeq[T]();
                helpvar`gensym142 = ""; group`gensym142 = "";
                required`gensym142 = false; optional`gensym142 = false;
                multi`gensym142 = false; env`gensym142 = ""): FileArg {.
    ...raises: [Exception], tags: [ReadEnvEffect, RootEffect], forbids: [].}
Template-defined constructor - see help for newStringArg for the meaning of parameters
proc newFloatArg(variants: seq[string]; help: string; default = 0.0;
                 choices = newSeq(); helpvar = ""; group = ""; required = false;
                 optional = false; multi = false; env = ""): FloatArg {.
    ...raises: [Exception], tags: [ReadEnvEffect, RootEffect], forbids: [].}
A FloatArg takes a float value
import options

let spec = (
    number: newFloatArg(@["-f", "--float"], help="A fraction input")
)
let (success, message) = parseOrMessage(spec, args="-f 0.25", command="hello")
doAssert success and message.isNone
doAssert spec.number.seen
doAssert spec.number.value == 0.25
proc newHelpArg(variants = @["-h", "--help"]; help = "Show help message";
                group = ""): HelpArg {....raises: [], tags: [], forbids: [].}
If a help arg is seen, a help message will be shown
import options
import strutils
let spec = (
    name: newStringArg(@["<name>"], help="Someone to greet"),
    times: newIntArg(@["-t", "--times"], help="How many times to greet them", helpvar="n"),
    help: newHelpArg(@["-h", "--help"], help="Show a help message"),
)
let prolog = "Greet someone"
let (success, message) = parseOrMessage(spec, prolog=prolog, args="-h", command="hello")
doAssert success and message.isSome
let expected = """
Greet someone

Usage:
  hello <name>
  hello -h|--help

Arguments:
  <name>           Someone to greet

Options:
  -t, --times=<n>  How many times to greet them
  -h, --help       Show a help message""".strip()
doAssert message.get == expected
proc newIntArg(variants: seq[string]; help: string; default = 0;
               choices = newSeq(); helpvar = ""; group = ""; required = false;
               optional = false; multi = false; env = ""): IntArg {.
    ...raises: [Exception], tags: [ReadEnvEffect, RootEffect], forbids: [].}
An IntArg takes an integer value
import options

let spec = (
    number: newIntArg(@["-n", "--number"], help="An integer input")
)
let (success, message) = parseOrMessage(spec, args="-n 10", command="hello")
doAssert success and message.isNone
doAssert spec.number.seen
doAssert spec.number.value == 10
proc newMessageArg(variants: seq[string]; message: string; help: string;
                   group = ""): MessageArg {....raises: [], tags: [], forbids: [].}
If a MessageArg is seen, a message will be shown
import options

let vspec = (
    version: newMessageArg(@["-v", "--version"], "0.1.0", help="Show the version")
)
let (success, message) = parseOrMessage(vspec, args="-v", command="hello")
doAssert success and message.isSome
doAssert message.get == "0.1.0"
proc newPathArg(variants`gensym165: seq[string]; help`gensym165: string;
                defaultVal`gensym165: T = ""; choices`gensym165 = newSeq[T]();
                helpvar`gensym165 = ""; group`gensym165 = "";
                required`gensym165 = false; optional`gensym165 = false;
                multi`gensym165 = false; env`gensym165 = ""): PathArg {.
    ...raises: [Exception], tags: [ReadEnvEffect, RootEffect], forbids: [].}
Template-defined constructor - see help for newStringArg for the meaning of parameters
proc newStringArg(variants: seq[string]; help: string; default = "";
                  choices = newSeq(); helpvar = ""; group = "";
                  required = false; optional = false; multi = false; env = ""): StringArg {.
    ...raises: [Exception], tags: [ReadEnvEffect, RootEffect], forbids: [].}
Creates a new Arg.
import options
import unittest

let spec = (
    src: newStringArg(@["<source>"], multi=true, help="Source file(s)"),
    dst: newStringArg(@["<destination>"], help="Destination")
)
let (success, message) = parseOrMessage(spec, args="this and_this to_here", command="cp")
test "Message test":
    check(success and message.isNone)
    check(spec.src.values == @["this", "and_this"])
    check(spec.dst.value == "to_here")
  • variants determines how the Arg is presented to the user and whether the arg is a positional argument (Argument) or an optional argument (Option)
    • Options take the form -o or --option (default to optional - override with required=true)
    • Arguments take the form <value> (default to required - override wiith optional=true)
    • Commands take the form command
  • help is a short form help message to explain what the argument does
  • default is a default value
  • choices is a set of allowed values for the argument
  • helpvar is a dummy variable name shown to the user in the help message forValueArg (i.e. --option <helpvar>). Defaults to the longest supplied variant
  • required implies that an optional argument must appear or parsing will fail
  • optional implies that a positional argument does not have to appear
  • multi implies that an Option may appear multiple times or an Argument consume multiple values

Notes:

  • multi is greedy -- the first time it is seen it will consume as many arguments as it can, while still allowing any remaining arguments to match
  • required and optional are mutually exclusive, but required=false does not imply optional=true and vice versa.
proc newStringPromptArg(variants: seq[string]; help: string; default = "";
                        choices = newSeq(); helpvar = ""; group = "";
                        required = false; optional = false; multi = false;
                        prompt: string; secret: bool; env = ""): StringPromptArg {.
    ...raises: [Exception], tags: [ReadEnvEffect, RootEffect], forbids: [].}
Experimental: Creates an argument whose value is read from a prompt rather than the commandline (e.g. a password)
  • prompt - prompt to display to the user to request input
  • secret - whether to display what the user tyeps (set to false for passwords)
proc newURLArg(variants`gensym176: seq[string]; help`gensym176: string;
               defaultVal`gensym176: T = parseUri("");
               choices`gensym176 = newSeq[T](); helpvar`gensym176 = "";
               group`gensym176 = ""; required`gensym176 = false;
               optional`gensym176 = false; multi`gensym176 = false;
               env`gensym176 = ""): URLArg {....raises: [Exception],
    tags: [ReadEnvEffect, RootEffect], forbids: [].}
Template-defined constructor - see help for newStringArg for the meaning of parameters
proc parse(specification: tuple; prolog = ""; epilog = "";
           args: seq[string] = commandLineParams();
           command = extractFilename(getAppFilename()))
Attempts to parse the input.
  • If the specification is incorrect (i.e. programmer error), SpecificationError is thrown
  • If the parse fails, ParserError is thrown
  • If the parse succeeds, but the user should be shown a message a MessageError is thrown
  • Otherwise, the parse has suceeded
proc parse(specification: tuple; prolog = ""; epilog = ""; args: string;
           command = extractFilename(getAppFilename()))
proc parseOrMessage(spec: tuple; prolog = ""; epilog = "";
                    args: seq[string] = commandLineParams();
                    command = extractFilename(getAppFilename())): tuple[
    success: bool, message: Option[string]]
Version of parse that returns success if the parse was sucessful. If the parse fails, or the result of the parse is an informationl message for the user, Option[str] will containing an appropriate message
proc parseOrMessage(spec: tuple; prolog = ""; epilog = ""; args: string;
                    command: string): tuple[success: bool,
    message: Option[string]]
Version of parseOrMessage that accepts args as a string for debugging sugar
proc parseOrQuit(spec: tuple; prolog = ""; epilog = "";
                 args: seq[string] = commandLineParams();
                 command = extractFilename(getAppFilename()))
Attempts to parse the input. If the parse fails or the user has asked for a message (e.g. help), show a message and quit
proc parseOrQuit(spec: tuple; prolog = ""; epilog = ""; args: string;
                 command: string)
Version of parseOrQuit taking args as a string for sugar
func seen(arg: Arg): bool {....raises: [], tags: [], forbids: [].}
seen returns true if the argument was seen in the input

Methods

method parse(arg: Arg; value: string; variant: string) {.base,
    ...raises: [ValueError], tags: [], forbids: [].}
parse is called when a value is seen for an argument. If you write your own Arg you will need to provide a parse implementation. If the value cannot be parsed, a ParseError is raised with a user-friendly explanation
method register(arg: Arg; variant: string) {.base, locks: "unknown",
    ...raises: [ParseError, ValueError], tags: [], forbids: [].}
register is called by the parser when an argument is seen. If you want to interupt parsing e.g. to print help, now is the time to do it
method register(arg: CountArg; variant: string) {.
    ...raises: [ParseError, ValueError], tags: [], forbids: [].}

Templates

template check_choices[T](arg: Arg; value: T; variant: string)
check_choices checks that value has been set to one of the acceptable choices values
template defineArg[T](TypeName: untyped; cons: untyped; name: string;
                      parseT: proc (value: string): T; defaultT: T)
defineArg is a concession to the power of magic. If you want to define your own ValueArg for type T, you simply need to pass in a method that is able to parse a string into a T and a sensible default value default(T) is often a good bet, but is not defined for all types. Beware, the error messages can get gnarly, and generated docstrings will be ugly
import times

# Decide on your default value
let DEFAULT_DATE = initDateTime(1, mJan, 2000, 0, 0, 0, 0)

# Define a parser
proc parseDate(value: string): DateTime = parse(value, "YYYY-MM-dd")

defineArg[DateTime](DateArg, newDateArg, "date", parseDate, DEFAULT_DATE)

# We can now use newDateArg to define an argument that takes a date

let spec = (
  date: newDateArg(@["<date>"], help="Date to change to")
)
spec.parse(args="1999-12-31", "set-party-date")

doAssert(spec.date.value == initDateTime(31, mDec, 1999, 0, 0, 0, 0))