• Stars
    star
    121
  • Rank 293,924 (Top 6 %)
  • Language
    Elixir
  • License
    Apache License 2.0
  • Created over 6 years ago
  • Updated over 1 year ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

A toolkit for creating terminal user interfaces in Elixir

Artificery

Module Version Hex Docs Total Download License Last Updated

Artificery is a toolkit for generating command line applications. It handles argument parsing, validation/transformation, generating help, and provides an easy way to define commands, their arguments, and options.

Installation

Just add Artificery to your deps:

defp deps do
  [
    # You can get the latest version information via `mix hex.info artificery`
    {:artificery, "~> x.x"}
  ]
end

Then run mix deps.get and you are ready to get started!

Defining a CLI

Let's assume you have an application named :myapp, let's define a module, MyCliModule which will be the entry point for the command line interface:

defmodule MyCliModule do
  use Artificery
end

The above will setup the Artificery internals for your CLI, namely it defines an entry point for the command line, argument parsing, and imports the macros for defining commands, options, and arguments.

Commands

Let's add a simple "hello" command, which will greet the caller:

defmodule MyCliModule do
  use Artificery

  command :hello, "Says hello" do
    argument :name, :string, "The name of the person to greet", required: true
  end
end

We've introduced two of the macros Aritificery imports: command, for defining top-level and nested commands; and argument for defining positional arguments for the current command. Note: argument can only be used inside of command, as it applies to the current command being defined, and has no meaning globally.

This command could be invoked (via escript) like so: ./myapp hello bitwalker. Right now this will print an error stating that the command is defined, but no matching implementation was exported. We define that like so:

def hello(_argv, %{name: name}) do
  Artificery.Console.notice "Hello #{name}!"
end

Note: Command handlers are expected to have an arity of 2, where the first argument is a list of unhandled arguments/options passed on the command line, and the second is a map containing all of the formally defined arguments/options.

This goes in the same module as the command definition, but you can use defdelegate to put the implementation elsewhere. The thing to note is that the function needs to be named the same as the command. You can change this however using an extra parameter to command, like so:

command :hello, [callback: :say_hello], "Says hello" do
  argument :name, :string, "The name of the person to greet", required: true
end

The above will invoke say_hello/2 rather than hello/2.

Command Flags

There are two command flags you can set currently to alter some of Artificery's behaviour: callback: atom and hidden: boolean. The former will change the callback function invoked when dispatching a command, as shown above, and the latter, when true, will hide the command from display in the help output. You may also apply :hidden to options (but not arguments).

Options

Let's add a --greeting=string option to the hello command:

command :hello, "Says hello" do
  argument :name, :string, "The name of the person to greet", required: true
  option :greeting, :string, "Sets a different greeting than \"Hello <name>\!""
end

And adjust our implementation:

def hello(_argv, %{name: name} = opts) do
  greeting = Map.get(opts, :greeting, "Hello")
  greet(greeting, name)
end
defp greet(greeting, name), do: Artificery.Console.notice("#{greeting} #{name}!")

And we're done!

Subcommands

When you have more complex command line interfaces, it is common to divide up "topics" or top-level commands into subcommands, you see this in things like Heroku's CLI, e.g. heroku keys:add. Artificery supports this by allowing you to nest command within another command. Artificery is smart about how it parses arguments, so you can have options/arguments at the top-level as well as in subcommands, e.g. ./myapp info --format=json processes. The options map received by the processes command will contain all of the options for commands above it.

defmodule MyCliModule do
  use Artificery

  command :info, "Get info about :myapp" do
    option :format, :string, "Sets the output format"

    command :processes, "Prints information about processes running in :myapp"
  end

Note: As you may have noticed above, the processes command doesn't have a do block, because it doesn't define any arguments or options, this form is supported for convenience.

Global Options

You may define global options which apply to all commands by defining them outside command:

defmodule MyCliModule do
  use Artificery

  option :debug, :boolean, "When set, produces debugging output"

  ...
end

Now all commands defined in this module will receive debug: true | false in their options map, and can act accordingly.

Reusing Options

You can define reusable options via defoption/3 or defoption/4. These are effectively the same as option/3 and option/4, except they do not define an option in any context, they are defined abstractly and intended to be used via option/1 or option/2, as shown below:

defoption :host, :string, "The hostname of the server to connect to",
  alias: :h

command :ping, "Pings the host to verify connectivity" do
  # With no overridden flags
  # option :host

  # With overrides
  option :host, help: "The host to ping", default: "localhost"
end

command :query, "Queries the host" do
  # Can be shared across commands, even used globally
  option :host, required: true
  argument :query, :string, required: true
end

Option/Argument Transforms

You can provide transforms for options or arguments to convert them to the data types your commands desire as part of the option definition, like so:

# Options
option :ip, :string, "The IP address of the host to connect to",
  transform: fn raw ->
    case :inet.parse_address(String.to_charlist(raw)) do
      {:ok, ip} ->
        ip
      {:error, reason} ->
        raise "invalid value for --ip, got: #{raw}, error: #{inspect reason}"
    end
  end

# Arguments
argument :ip, :string, "The IP address of the host to connect to",
  transform: ...

Now the command (and any subcommands) where this option is defined will get a parsed IP address, rather than a raw string, allowing you to do the conversion in one place, rather than in each command handler.

Currently this macro supports functions in anonymous form (like in the example above), or one of the following forms:

# Function capture, must have arity 1
transform: &String.to_atom/1

# Local function as an atom, must have arity 1
transform: :to_ip_address

# Module/function/args tuple, where the raw value is passed as the first argument
# This form is invoked via `apply/3`
transform: {String, :to_char_list, []}

Pre-Dispatch Handling

For those cases where you need to perform some action before command handlers are invoked, perhaps to apply global behaviour to all commands, start applications, or whatever else you may need, Artificery provides a hook for that, pre_dispatch/3.

This is actually a callback defined as part of the Artificery behaviour, but is given a default implementation. You can override this implementation though to provide your own pre-dispatch step.

The default implementation is basically the following:

def pre_dispatch(%Artificery.Command{}, _argv, %{} = options) do
  {:ok, options}
end

You can either return {:ok, options} or raise an error, there are no other choices permitted. This allows you to extend or filter options, handle additional arguments in argv, or take action based on the current command.

Writing Output / Logging

Artificery provides a Console module which contains a number of functions for logging or writing output to standard out/standard error. A list of basic functions it provides is below:

  • configure/1, takes a list of options which configures the logger, currently the only option is :verbosity
  • debug/1, writes a debug message to stderr (colored cyan if terminal supports color)
  • info/1, writes an info message to stdout (no color)
  • notice/1, writes an informational notice to stdout (bright blue)
  • success/1, writes a success message to stdout (bright green)
  • warn/1, writes a warning to stderr (yellow)
  • error/1, writes an error to stderr (red), and also halts/terminates the process with a non-zero exit code

In addition to writing messages to the terminal, Console also provides a way to provide a spinner/loading animation while some long-running work is being performed, also supporting the ability to update the message with progress information.

The following example shows a trivial example of progress, by simply reading from a file in a loop, updating the status of the spinner while it reads. There are obviously cleaner ways of writing this, but hopefully it is clear what the capabilities are.

def load_data(_argv, %{path: path}) do
  alias Artificery.Console

  unless File.exists?(path) do
    Console.error "No such file: #{path}"
  end

  # A state machine defined as a recursive anonymous function
  # Each state updates the spinner status and is reflected in the console
  loader = fn
    :opening, _size, _bytes_read, _file, loader ->
      Console.update_spinner("opening #{path}")
      %{size: size} = File.stat!(path)
      loader.(:reading, size, 0, File.open!(path), loader)

    :reading, size, bytes_read, file, loader ->
      progress = Float.round((size / bytes_read) * 100)
      Console.update_spinner("reading..#{progress}%")
      case IO.read(file) do
        :eof ->
          loader.(:done, size, bytes_read, file, loader)

        {:error, _reason} = err ->
          Console.update_spinner("read error!")
          File.close!(file)
          err

        new_data ->
          loader.(:reading, size, byte_size(new_data), file, loader)
      end

    :done, size, bytes_read, file, loader ->
      Console.update_spinner("done! (total bytes read #{bytes_read})")
      File.close!(file)
      :ok
  end

  results =
    Console.spinner "Loading data.." do
      loader.(:opening, 0, 0, nil, loader)
    end

  case results do
    {:error, reason} ->
      Console.error "Failed to load data from #{path}: #{inspect reason}"

    :ok ->
      Console.success "Load complete!"
  end
end

Handling Input

Artificery exposes some functions for working with interactive user sessions:

  • yes?/1, asks the user a question and expects a yes/no response, returns a boolean
  • ask/2, queries the user for information they need to provide

Example

Let's shoot for a slightly more amped up hello command:

def hello(_argv, _opts) do
  name = Console.ask "What is your name?", validator: &is_valid_name/1
  Console.success "Hello #{name}!"
end

defp is_valid_name(name) when byte_size(name) > 1, do: :ok
defp is_valid_name(_), do: {:error, "You must tell me your name or I can't greet you!"}

The above will accept any name more than one character in length, obviously not super robust, but the general idea is shown here.

The ask function also supports transforming responses, and providing defaults in the case where you want to accept blank answers.

Check the docs for more information!

Producing An Escript

To use your newly created CLI as an escript, simply add the following to your mix.exs:

defp project do
  [
    ...
    escript: escript()
  ]
end

...

defp escript do
  [main_module: MyCliModule]
end

The main_module to use is the module in which you added use Artificery, i.e. the module in which you defined the commands your application exposes.

Finally, run mix escript.build to generate the escript executable. You can then run ./yourapp help to test it out.

Using In Releases

If you want to define the CLI as part of a larger application, and consume it via custom commands in Distillery, it is very straightforward to do. You'll need to define a custom command and add it to your release configuration:

# rel/config.exs

release :myapp do
  set commands: [
    mycli: "rel/commands/mycli.sh"
  ]
end

Then in rel/commands/mycli.sh add the following:

#!/usr/bin/env bash

elixir -e "MyCliModule.main" -- "$@"

Since the code for your application will already be on the path in a release, we simply need to invoke the CLI module and pass in arguments. We add -- between the elixir arguments and those provided from the command line to ensure that they are not treated like arguments to our CLI. Artificery handles this, so you simply need to ensure that you add -- when invoking via elixir like this.

You can then invoke your CLI via the custom command, for example, bin/myapp mycli help to print the help text.

Roadmap

  • Support validators

I'm open to suggestions, just open an issue titled RFC: <feature you are requesting>.

License

Copyright (c) 2018 Paul Schoenfelder

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

More Repositories

1

distillery

Simplify deployments in Elixir with OTP releases!
Elixir
2,944
star
2

timex

A complete date/time library for Elixir projects.
Elixir
1,732
star
3

swarm

Easy clustering, registration, and distribution of worker processes for Erlang/Elixir
Elixir
1,201
star
4

exrm

Automatically generate a release for your Elixir project!
Elixir
923
star
5

exprotobuf

Protocol Buffers in Elixir made easy!
Elixir
485
star
6

libgraph

A graph data structure library for Elixir projects
Elixir
454
star
7

conform

Easy, powerful, and extendable configuration tooling for releases.
Elixir
378
star
8

keys.js

Easy keybindings for browser applications!
JavaScript
360
star
9

alpine-elixir-phoenix

An Alpine Linux base image containing Elixir, Erlang, Node, Hex, and Rebar. Ready for Phoenix applications!
Makefile
349
star
10

alpine-elixir

A Dockerfile based on my alpine-erlang image for Elixir applications
Makefile
202
star
11

toml-elixir

An implementation of TOML for Elixir projects, compliant with the latest specification
Elixir
196
star
12

combine

A parser combinator library for Elixir projects
Elixir
194
star
13

libring

A fast consistent hash ring implementation in Elixir
Elixir
192
star
14

timex_ecto

An adapter for using Timex DateTimes with Ecto
Elixir
161
star
15

exirc

IRC client adapter for Elixir projects
Elixir
152
star
16

alpine-erlang

An alpine image with Erlang installed, intended for releases
Dockerfile
82
star
17

strukt

Extends defstruct with schemas, changeset validation, and more
Elixir
71
star
18

ex_unit_clustered_case

An extension for ExUnit for simplifying tests against a clustered application
Elixir
57
star
19

uniq

Provides UUID generation, parsing, and formatting. Supports RFC 4122, and the v6 draft extension
Elixir
52
star
20

distillery-aws-example

An example application to go with the AWS guide in the Distillery documentation
Elixir
50
star
21

distillery-umbrella-test

An example Elixir application for working with umbella apps and Distillery.
Elixir
49
star
22

pluginhost

An example C# application which provides runtime extensibility via plugins
C#
31
star
23

picosat_elixir

Elixir + Erlang bindings for the PicoSAT solver
C
17
star
24

docker-release-toolkit

My personal toolkit for building releases with Docker
Makefile
16
star
25

distillery-test

Elixir application which demonstrates a bare-minimum release-ready app using Distillery.
Elixir
16
star
26

8bit-background

A sweet series of 8-bit backgrounds, which changes based on the time of day.
Shell
15
star
27

libswagger

A Swagger client library for Elixir projects
Elixir
15
star
28

stringex

A string extensions library for node.js
JavaScript
15
star
29

scaladiff

A diff library built in Scala, for Scala projects
Scala
13
star
30

dotfiles

My assorted dotfiles
Shell
10
star
31

resume

The TeX source for my current resume.
TeX
7
star
32

fogbugz-cli

FogBugz Command Line Client
Ruby
7
star
33

aria

An experiment in programming language design
7
star
34

s2i-alpine-base

An S2I base image using Alpine Linux
Python
5
star
35

RPNCalculator

Reverse Polish Notation calculator built in Scala for a impromptu code challenge at work
Scala
5
star
36

uniq_compat

A compatibility shim for ::elixir_uuid when used with :uniq
Elixir
4
star
37

aws-dist-test

Clone of distillery-aws-example to illustrate distribution
Elixir
4
star
38

centos7-elixir

A CentOS7 base image for use with the Distillery AWS guide
Dockerfile
4
star
39

firefly

TBD
Elixir
3
star
40

conform_exrm

Conform plugin for ExRM
Elixir
3
star
41

alpine-toolbox

A toolbox image based on Alpine Linux for when I want to troubleshoot things in my OpenShift cluster
Makefile
3
star
42

SharpTools

A collection of practical C# code to build upon
C#
2
star
43

functools

Functional programming tools for Go
Go
2
star
44

toolbox.js

A general purpose javascript utility library. Contains everything you need, and most anything worth having (other than DOM manipulation).
JavaScript
1
star
45

erl_tar2

A re-implementation of erl_tar to support common tar archive formats
Erlang
1
star
46

s2i-elixir

An S2I image which provides Elixir, Erlang, and Node.js
Shell
1
star
47

blog

My personal blog source
CSS
1
star
48

s2i-erlang

An S2I image which provides Erlang and Node.js
Shell
1
star