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
CommandArg = ref object of Arg
- CommandArg represents a subcommand, which will be processed with its own parser
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.
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]
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.
- variants determines how the Arg is presented to the user and whether the arg is a positional argument (Argument) or an optional argument (Option)
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
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
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))