CliOptions (CliOptions v0.1.6)
View SourceAn opinionated command line arguments parser.
Features
The main features of CliOptions
are:
- Schema based validation of command line arguments.
- Support for common options types and custom validations.
- Strict validation by default, an error is returned if an invalid. argument is passed.
- Supports required options, default values, aliases, mutually
exclusive options and much more (check
CliOptions.Schema
for more details). - Auto-generated docs for the supported arguments.
Usage
The main function of this module is parse/2
which parses a list
of command line options and arguments into a tuple of parsed options,
positional arguments and extra options (anything passed after the --
separator).
iex> schema = [
...> name: [
...> type: :string,
...> required: true
...> ],
...> num: [
...> type: :integer,
...> default: 4,
...> short: "n"
...> ]
...> ]
...>
...> CliOptions.parse(["--name", "foo"], schema)
{:ok, {[name: "foo", num: 4], [], []}}
iex> # with positional arguments
...> CliOptions.parse(["--name", "foo", "-n", "2", "foo.ex"], schema)
{:ok, {[name: "foo", num: 2], ["foo.ex"], []}}
iex> # with positional arguments and extra options
...> CliOptions.parse(["--name", "foo", "-n", "2", "file.txt", "--", "-n", "1"], schema)
{:ok, {[name: "foo", num: 2], ["file.txt"], ["-n", "1"]}}
iex> # with invalid options
...> CliOptions.parse(["--user-name", "foo"], schema)
{:error, "invalid option \"user-name\""}
Check the parse/2
documentation for more details.
Strict validation
Notice that CliOptions
adheres to a strict options validation approach. This
means that an error will be returned in any of the following cases:
- An invalid option is provided
- An option is provided more times than expected
- The option's type is not valid
- A required option is not provided
iex> schema = [
...> number: [type: :integer],
...> name: [type: :string, required: true]
...> ]
...>
...> # with invalid option
...> CliOptions.parse(["--name", "foo", "--invalid"], schema)
{:error, "invalid option \"invalid\""}
iex> # with missing required option
...> CliOptions.parse([], schema)
{:error, "option :name is required"}
iex> # with invalid type
...> CliOptions.parse(["--name", "foo", "--number", "asd"], schema)
{:error, ":number expected an integer argument, got: asd"}
iex> # with option re-definition
...> CliOptions.parse(["--name", "foo", "--name", "bar"], schema)
{:error, "option :name has already been set with foo"}
Summary
Functions
Returns documentation for the given schema.
Parses argv
according to the provided schema
.
Similar as parse/2
but raises an CliOptions.ParseError
exception if invalid
CLI arguments are given.
Types
Functions
@spec docs(schema :: keyword() | CliOptions.Schema.t(), opts :: keyword()) :: String.t()
Returns documentation for the given schema.
You can use this to inject documentation in your docstrings. For example, say you have your schema in a mix task:
@options_schema [...]
With this, you can use docs/2
to inject documentation:
## Supported Options
#{CliOptions.docs(@options_schema)}"
Options
:sort
- if set totrue
the options will be sorted alphabetically.:sections
- a keyword list with options sections. If set the options docs will be added under the defined section, or at the root section if no:section
is defined in your schema.Notice that if
:sort
is set the options will be sorted within the sections. The sections order is not sorted and it follows the provided order.An entry for each section is expected in the
:sections
option with the following format:[ section_name: [ header: "Section Header", doc: "Optional extra docs for this docs section" ] ]
where:
:header
- The header that will be used for the section. Required.:doc
- Optional detailed section docs to be added before the actual options docs.
Parses argv
according to the provided schema
.
schema
can be either a CliOptions.Schema
struct, or a keyword list. In
the latter case it will be first initialized to a CliOptions.Schema
.
If the provided CLI arguments are valid, then an {:ok, parsed}
tuple is
returned, where parsed
is a three element tuple of the form {opts, args, extra}
,
where:
opts
- the extracted command line optionsargs
- a list of the remaining arguments inargv
as stringsextra
- a list of unparsed arguments, if applicable.
If validation fails, an {:error, message}
tuple will be returned.
Creating the schema at compile time
To avoid the extra cost of initializing the schema, it is possible to create
the schema once, and then use that valid schema directly. This is done by
using the CliOptions.Schema.new!/1
function first, and then passing the
returned schema to parse/2
.
Usually you will define the schema as a module attribute and then use it in your mix task.
defmodule Mix.Tasks.MyTask do
use Mix.Task
schema = [
user_name: [type: :string],
verbose: [type: :boolean]
]
@schema CliOptions.Schema.new!(schema)
@impl Mix.Task
def run(argv) do
{opts, args, extra} = CliOptions.parse!(args, @schema)
# your task code here
end
end
Options names and aliases
By default CliOptions
will use the hyphen-ized version of the option name
as the long name of the option.
iex> CliOptions.parse(["--user-name", "John"], user_name: [type: :string])
{:ok, {[user_name: "John"], [], []}}
Additionally you are allowed to set a short version (one-letter string) for the option.
iex> CliOptions.parse(["-U", "John"], user_name: [type: :string, short: "U"])
{:ok, {[user_name: "John"], [], []}}
You are also allowed to specifically set a long name for the option. In this case the auto-generated long name from the option name will not valid.
iex> CliOptions.parse(["-U", "John"], user_name: [type: :string, long: "user"])
{:error, "invalid option \"U\""}
iex> CliOptions.parse(
...> ["--user", "John"],
...> user_name: [type: :string, long: "user"]
...> )
{:ok, {[user_name: "John"], [], []}}
Additionally you can provide an arbitrary number of long and short aliases.
iex> schema = [
...> user_name: [
...> type: :string,
...> short: "u",
...> aliases: ["user", "user_name"],
...> short_aliases: ["U"]
...> ]
...> ]
...>
...> # all following argv are equivalent
...> inputs = [
...> ["--user-name", "John"],
...> ["--user", "John"],
...> ["--user_name", "John"],
...> ["-u", "John"],
...> ["-U", "John"]
...> ]
...>
...> for argv <- inputs, {opts, [], []} = CliOptions.parse!(argv, schema) do
...> opts == [user_name: "John"]
...> end
...> |> Enum.all?()
true
Strict parsing by default
Notice that CliOptions
will return an error if a not expected option is
encountered. Only options defined in the provided schema are allowed. If you
need to support arbitrary options you will have to add them after the return
separator and handle them in your application code.
iex> schema = [
...> file: [
...> type: :string,
...> required: true
...> ],
...> number: [
...> type: :integer,
...> short: "n"
...> ]
...> ]
...>
...> # parses valid arguments
...> CliOptions.parse(["--file", "foo.ex", "-n", "2"], schema)
{:ok, {[file: "foo.ex", number: 2], [], []}}
iex> # error if invalid argument is encountered
...> CliOptions.parse(["--file", "foo.ex", "-b", "2"], schema)
{:error, "invalid option \"b\""}
iex> # you can add extra arguments after the return separator
...> CliOptions.parse(["--file", "foo.ex", "--", "-b", "2"], schema)
{:ok, {[file: "foo.ex"], [], ["-b", "2"]}}
Option types
By default all options are assumed to be strings. If you set a different :type
then
the option will be casted to that type. The supported types are:
:string
- the default, parses the argument as a string:integer
- parses the argument as an integer:float
- parses the argument as a float:atom
- converts the arguments to atoms:boolean
- parses the argument as a flag, e.g. no option is expected.:counter
- treats the argument as a flag that increases an associated counter
iex> schema = [
...> user: [
...> type: :string
...> ],
...> age: [
...> type: :integer
...> ],
...> height: [
...> type: :float
...> ]
...> ]
...>
...> {:ok, options} =
...> CliOptions.parse(
...> ["--user", "John", "--age", "34", "--height", "1.75"],
...> schema
...> )
...>
...> options
{[user: "John", age: 34, height: 1.75], [], []}
Counters
In some command line applications you may want to count how many times an option is given. In
this case no option argument is expected. Instead every time the option is encountered a counter
is incremented. You can define such an option by setting the type to :counter
.
iex> schema = [
...> verbosity: [
...> type: :counter,
...> short: "v"
...> ]
...> ]
...>
...> # counts the number -v flag is given
...> CliOptions.parse(["-v", "-v", "-v"], schema)
{:ok, {[verbosity: 3], [], []}}
iex> # if not set it is set to zero
...> CliOptions.parse([], schema)
{:ok, {[verbosity: 0], [], []}}
Default values and required options
Options can have default values. If no command line argument is provided then the parsed options will return the default value instead. For example:
iex> schema = [
...> verbose: [
...> type: :boolean,
...> default: true
...> ],
...> retries: [
...> type: :integer,
...> default: 1
...> ]
...> ]
...>
...> CliOptions.parse([], schema)
{:ok, {[verbose: true, retries: 1], [], []}}
Booleans and counters
Notice that for options of type :boolean
or :counter
a default value is always
implicitly set.
iex> schema = [verbose: [type: :boolean], level: [type: :counter]]
...>
...> CliOptions.parse([], schema)
{:ok, {[verbose: false, level: 0], [], []}}
Additionally you can mark an option as required. In this case an error will be returned if the option is not present in the command line arguments.
iex> schema = [file: [type: :string, required: true]]
...>
...> CliOptions.parse([], schema)
{:error, "option :file is required"}
iex> CliOptions.parse(["--file", "foo.ex"], schema)
{:ok, {[file: "foo.ex"], [], []}}
Options with multiple arguments
If you want to pass a cli option multiple times you can set the :multiple
option set to
true
. In this case every time the option is encountered the value will be appended to
the previously encountered values.
iex> schema = [
...> file: [
...> type: :string,
...> multiple: true,
...> short: "f"
...> ],
...> number: [
...> type: :integer,
...> multiple: true,
...> short: "n"
...> ]
...> ]
...>
...> # all file values are appended to the file option
...> CliOptions.parse(["-f", "foo.ex", "--file", "bar.ex", "-n", "1", "-n", "2"], schema)
{:ok, {[file: ["foo.ex", "bar.ex"], number: [1, 2]], [], []}}
iex> # notice that if an argument is passed once, the value will still be a list
...> CliOptions.parse(["-f", "foo.ex"], schema)
{:ok, {[file: ["foo.ex"]], [], []}}
iex> # all passed items are validated based on the expected type
...> CliOptions.parse(["-n", "2", "-n", "xyz"], schema)
{:error, ":number expected an integer argument, got: xyz"}
iex> # if multiple is not set then an error is returned if an option is passed twice
...> CliOptions.parse(["--file", "foo.ex", "--file", "xyz.ex"], file: [type: :string])
{:error, "option :file has already been set with foo.ex"}
Additionally you can specify the :separator
option which allows you to pass
multiple values grouped together instead of providing the same option multiple
times. Notice that this is applicable only if :multiple
is set to true
.
iex> schema = [
...> project: [
...> type: :string,
...> multiple: true,
...> separator: ",",
...> short: "p"
...> ]
...> ]
...>
...> # pass all values grouped with the separator
...> CliOptions.parse(["-p", "foo,bar,baz"], schema)
{:ok, {[project: ["foo", "bar", "baz"]], [], []}}
iex> # you are still free to pass the `-p` flag multiple times
...> CliOptions.parse(["-p", "foo,bar", "-p", "baz"], schema)
{:ok, {[project: ["foo", "bar", "baz"]], [], []}}
Mutually exclusive options
If an argument is mutually exclusive with some other argument(s) then you can set
it in the :conflicts_with
option. If two conflicting options are provided together
then the argument parsing will fail.
iex> schema = [
...> verbose: [type: :boolean, conflicts_with: [:silent]],
...> silent: [type: :boolean]
...> ]
...>
...> # if only one is passed then the arguements are valid
...> CliOptions.parse(["--verbose"], schema)
{:error, "--verbose is mutually exclusive with --silent"}
iex> # passing both makes the parser to fail
...> CliOptions.parse(["--verbose", "--silent"], schema)
{:error, "--verbose is mutually exclusive with --silent"}
Defining conflicting arguments
Defining a conflict is two-way, but does not need to be defined for both
arguments. For example if :foo
is conflicting with :bar
it is sufficient
to define the conflict in one of the two.
iex> schema = [
...> foo: [type: :boolean, conflicts_with: [:bar]],
...> bar: [type: :boolean]
...> ]
...>
...> CliOptions.parse(["--foo", "--bar"], schema)
{:error, "--foo is mutually exclusive with --bar"}
iex> schema = [
...> foo: [type: :boolean],
...> bar: [type: :boolean, conflicts_with: [:foo]]
...> ]
...>
...> CliOptions.parse(["--foo", "--bar"], schema)
{:error, "--bar is mutually exclusive with --foo"}
Environment variable aliases
You can optionally define an environment variable alias for an option through the
:env
schema option. If set the environment variable will be used only if the
argument is not present in the args
.
iex> schema = [
...> mode: [
...> type: :string,
...> env: "CLI_OPTIONS_MODE"
...> ]
...> ]
...>
...> # assume the environment variable is set
...> System.put_env("CLI_OPTIONS_MODE", "parallel")
...>
...> # if the argument is provided by the user the environment variable is ignored
...> CliOptions.parse(["--mode", "sequential"], schema)
{:ok, {[mode: "sequential"], [], []}}
iex> # the environment variable will be used if not set
...> CliOptions.parse([], schema)
{:ok, {[mode: "parallel"], [], []}}
iex> System.delete_env("CLI_OPTIONS_MODE")
:ok
Boolean flags and environment variables
Notice that if the option is :boolean
and an :env
alias is set, then the
environment variable will be used only if it has a truthy value. A value is
considered truthy if it is one of 1
, true
(the match is case insensitive).
In any other case the environment variable is ignored.
iex> schema = [
...> enable: [type: :boolean, env: "CLI_OPTIONS_ENABLE"]
...> ]
...>
...> System.put_env("CLI_OPTIONS_ENABLE", "1")
...> CliOptions.parse([], schema)
{:ok, {[enable: true], [], []}}
iex> System.put_env("CLI_OPTIONS_ENABLE", "TrUE")
...> CliOptions.parse([], schema)
{:ok, {[enable: true], [], []}}
iex> System.put_env("CLI_OPTIONS_ENABLE", "other")
...> CliOptions.parse([], schema)
{:ok, {[enable: false], [], []}}
iex> System.delete_env("CLI_OPTIONS_ENABLE")
:ok
Return separator
The separator --
implies options should no longer be processed. Every argument
after the return separator will be added in the extra
field of the response.
iex> CliOptions.parse!(["--", "lib", "-n"], [])
{[], [], ["lib", "-n"]}
Notice that if the remaining arguments contain another return separator this will included in the extra:
iex> CliOptions.parse!(["--", "lib", "-n", "--", "bar"], [])
{[], [], ["lib", "-n", "--", "bar"]}
Post validation
In some cases you may need to perform more complex validation on the provided
CLI arguments that cannot be performed by the parser itself. You could do it
directly in your codebase but for your convenience CliOptions.parse/2
allows
you to pass an optional :post_validate
argument. This is expected to be a
function having as input the parsed options and expected to return an
{:ok, parsed_options()}
or an {:error, String.t()}
tuple.
Let's see an example:
iex> schema = [
...> silent: [type: :boolean],
...> verbose: [type: :boolean]
...> ]
...>
...> # the flags --verbose and --silent should not be set together
...> post_validate =
...> fn {opts, args, extra} ->
...> if opts[:verbose] and opts[:silent] do
...> {:error, "flags --verbose and --silent cannot be set together"}
...> else
...> {:ok, {opts, args, extra}}
...> end
...> end
...>
...> # without post_validate
...> CliOptions.parse(["--verbose", "--silent"], schema)
{:ok, {[silent: true, verbose: true], [], []}}
iex> # with post_validate
...> CliOptions.parse(["--verbose", "--silent"], schema, post_validate: post_validate)
{:error, "flags --verbose and --silent cannot be set together"}
iex> # if only one of the two is passed the validation succeeds
...> CliOptions.parse(["--verbose"], schema, post_validate: post_validate)
{:ok, {[silent: false, verbose: true], [], []}}
@spec parse!( argv :: argv(), schema :: keyword() | CliOptions.Schema.t(), opts :: Keyword.t() ) :: parsed_options()
Similar as parse/2
but raises an CliOptions.ParseError
exception if invalid
CLI arguments are given.
If there are no errors an {opts, args, extra}
tuple is returned.
Examples
iex> CliOptions.parse!(["--file", "foo.ex"], [file: [type: :string]])
{[file: "foo.ex"], [], []}
iex> CliOptions.parse!([], [file: [type: :string, required: true]])
** (CliOptions.ParseError) option :file is required