Suppose you have a command-line application that uses some number of, oh, let's say "connections". Most of the time, a user will want the same number of connections, so you'll let them set a personal default in a configuration file. Sometimes they want to change the default. Maybe they'll want a one-time change; for that, a command-line option is best. But sometimes they'll want to make the change for an entire login session; for that, setting an environment variable is most convenient.

The User-Choices gem gives your code a unified interface to all those sources. Your code can obey the user's choices without having to bother (much) about how she made them.

This tutorial explains how to set up that interface. See also the API documentation. You can get the source from the downloads page or, less directly, through the project page.

1. Using the choices during execution
2. Defining the sources
    Environment variables
    YAML files
    XML files
    The command line
3. Defining the choices
    3.1 Keyword arguments for add_choice
    3.2 Extra behavior for the command line
4. Optionally touching up choices before execution
5. Notes

1. Using the choices during execution

Your program will use a variant of the Command pattern. Its rough structure will look like this:

require 'user-choices'
 
class TutorialExample < UserChoices::Command
  include UserChoices
 
  def add_sources(builder)...
  def add_choices(builder)...

  def execute
    puts "There are #{@user_choices[:connections]} connections."
    pp @user_choices
  end
end
 
 
if $0 == __FILE__
  TutorialExample.new.execute
end

Your entire program runs within the execute method of your Command object. The instance variable @user_choices is a hash whose keys are symbols you've used to name the choices. Its values are the user's choices.

Within add_choices I've set up the choices to default to 0, so if I were to run the program without making any choices, I'd get this result:

$ ruby tutorial1.rb
There are 0 connections.
{:connections=>0}

In the add_sources method, I've also told TutorialExample to obey a .myprog-config.yml file in my home directory. Suppose it looks like this:

connections: 19

In that case, the output would be:

$ ruby tutorial1.rb
There are 19 connections.
{:connections=>19}

The configuration file value took precedence over the default. An environment variable's value can, in turn, take precedence over that:

$ (export myprog_connections=3; ruby tutorial1.rb)
There are 3 connections.
{:connections=>3}

And a command-line choice can take precedence over the environment:

$ (export myprog_connections=3; ruby tutorial1.rb --connections 999)
There are 999 connections.
{:connections=>999}

Part of the point of User-Choices is reasonably helpful error messages. For example, here's the result of a bad value for the number of connections:

> (export myprog_connections=hi; ruby tutorial1.rb)
Error in the environment: myprog_connections's value must be an integer, and 'hi' doesn't look right.

Notice that the error messsage is in terms of the source (the environment variable "myprog_connections", not the internal symbol :connections).

In the case of a command-line error, more help text is printed:

$ ruby tutorial1.rb --connections hi
Error in the command line: --connections's value must be an integer, and 'hi' doesn't look right.
Usage: ruby tutorial1.rb [options]

Options:
-c, --connections COUNT     Number of connections to open.
-?, -h, --help              Show this message.

2. Defining the sources

The different sources for the tutorial program are configured in add_sources:

  def add_sources(builder)
    builder.add_source(CommandLineSource, :usage,
                       "Usage ruby #{$0} [options]")
    builder.add_source(EnvironmentSource, :with_prefix, "myprog_")
    builder.add_source(YamlConfigFileSource, :from_file, ".myprog-config.yml")
  end
 

Behind the scenes, User-Choices creates a ChoicesBuilder object it hands to the configuration method add_sources. To harvest choices from a new source, you identify it to the builder with an add_source call. The calls should be made in precedence order, highest to lowest. A source for default values comes automatically, so you don't need to list it.

The sources are distinguished by the names of classes. Each class takes different arguments. Notice that the arguments are in a weird pseudo-keyword style:

builder.add_source(ClassName, :symbol, one or more args, :symbol, one or more args, ...)

I make no apologies. Well, maybe one or two.

EnvironmentSource

There are two ways of specifying which environment variables should be considered choices for this program.

  • builder.add_source(EnvironmentSource, :with_prefix, "prefix") says that any environment variable beginning with the prefix is a user choice that matters to this program. The symbol-name of the choice is constructed from the environment variable name, less the prefix. So if the prefix is "amazon_", the environment variable "amazon_login" produces choice :login.

  • builder.add_source(EnvironmentSource, :mapping, hash) gives an explicit map between choice names and environment variable names. So if the hash is {:home => "HOME", :shell_level => "SHLVL"}, the program would (on my machine) have @user_choices[:home] be "/Users/marick" and @user_choices[:shell_level] be 1.

You can use both ways by chaining them together:

   builder.add_source(EnvironmentSource, :with_prefix, "prefix_", :mapping, {:home => "HOME" })
YamlConfigFileSource

builder.add_source(YamlConfigFileSource, :from_file, "filename") says that the user choices are in a YAML file named "filename" in the user's home directory. The home directory is found the same way RubyGems finds it.

You can use :from_complete_path if your YAML file isn't in the home directory. In that case, the next argument is a path to the YAML file. It can be either relative or absolute.

YAML files should contain a single level of keys and values. The values can be numbers, strings, and arrays of numbers or strings. Here is an acceptable file:

ordinary_choice: 2
names:
   - dawn
   - paul
   - sophie

The keys are turned into symbols and become the choice names. The above file produces :ordinary_choice and :names. If the key has a dash in it, that's converted to an underscore.

The values, by default, are strings (or arrays of strings), though that can be overridden when the choice is described.

The results of more complicated files are undefined.

XmlConfigFileSource

builder.add_source(XmlConfigFileSource, :from_file, "filename") says that the user choices are in a XML file named "filename" in the user's home directory. The home directory is found the same way RubyGems finds it.

You can use :from_complete_path if your XML file isn't in the home directory. In that case, the next argument is a path to the XML file. It can be either relative or absolute.

Here is an acceptable XML file:

<config>
   <ordinary_choice>2</ordinary_choice>
   <names>dawn</names>
   <names>paul</names>
   <names>sophie</names>
</config>

The root tag (<config>) is irrelevant. Name it what you like. The tag names are converted into choice symbols, so <ordinary_choice> becomes :ordinary_choice. If the tag name contains a dash, it's converted into an underscore.

Tag text contents have whitespace stripped off, then become string choice values. (This default typing can be changed in add_choices.) If you want an array of values, you repeat the tag multiple times (as in <names> above).

The results of more complicated files are undefined.

CommandLineSource

User-Choices uses OptionParser to handle command lines. Arguments to builder.add_source and (later) builder.add_choice are passed along to OptionParser to help it do its work. Part of its work is generating helpful output to show on request or in the case of a user error, and the add_source call helps with that.

builder.add_source(CommandLineSource, :usage, "line"...) gives OptionParser lines to print before it describes command-line options. Here's a typical example:

    builder.add_source(CommandLineSource, :usage,
                       "Usage: ruby #{$0} [options] input-file output-file",
                       "Encode the input file into the output file.")
 

3. Defining the choices

Once the sources of choices have been named, each different choice needs to be described. Here's the description for the tutorial program:

def add_choices(builder)
  builder.add_choice(:connections, :type=>:integer, :default=>0) { | command_line |
    command_line.uses_option("-c", "--connections COUNT",
                           "Number of connections to open.")
  }
end

In this case, there's one choice (:connections). The :type keyword arguments tells User-Choices to convert a string fetched from any defined source (in this case, the command line) into an integer. The :default keyword argument gives a value to use if the user doesn't give one on the command line.

The block argument is used to do additional setup for the command line. That'll be explained shortly.

3.1 Keyword arguments for add_choice

  • The :type keyword describes type checking and conversion that should be done. If no :type argument is given, the choice stays a string.

    Default choices are handled generously. Suppose a particular choice is to be an integer. You can specify the default value as either the string "1" (in which case it will be converted to an integer) or the integer 1 (in which case it will be left alone).

    A StandardError is raised if the conversion can't be done.

    Normal YAML parsing converts text into native types. (A YAML line like "count: 1" is automatically parsed into {"count" => 1}.) These conversions do not happen in User-Choices because there's no equivalent for other sources like XML.)

    :type => :integer

    Convert the string into an integer. Accepts whatever String#to_i does.

    :type => :boolean

    Convert the string into either true or false. The string must be one of "true" or "false" (case-insensitive).

    :type => :string

    Leave the string alone. Since that's the default behavior, this case is only for convenience.

    :type => [:string]

    The string is split at comma boundaries to become an array of strings. Whitespace is not stripped. This type need only be declared when the data could come from a command-line argument of this form: --names a,b,c or an environment variable like NAMES="a,b,c". YAML and XML files describe the data so that User-Choices doesn't need help to know you want an array.

    :type => ["one", "two", ...]

    The value chosen must be one of the given strings. There's no conversion.

  • The :length keyword applies only when the given value is converted to an array. It takes either an integer or range. A StandardError with a helpful message is raised if the actual length doesn't match.

  • The :default keyword gives a default value for a choice. That value is type-checked if a type is given. (But note that type-checking succeeds if the default value is already of the correct type. There's no need to use "1" for a choice of type :integer. You can use the more natural 1. )

    If no default is given, and no value is specified in any source, the choice-symbol (e.g., :connections) is not a key in the @user_choices hash (and so @user_choices[:connections] would be nil and @user_choices.has_key?(:connections) would be false).

3.2 Extra behavior for the command line

The block given to :add_choice serves as a front end to OptionParser. It also lets you treat command-line arguments as just another kind of user choice (rather than having to mess around with ARGV).

Within the block, you can send these messages to the block's argument:

uses_option(string...)

The strings are passed on to OptionParser#on. There are quite possibly variations that don't work well. (If you find any, send me mail.) The common variation that definitely works looks like this:

      command_line.uses_option("-c", "--connections COUNT",
                               "Number of connections to open.")

The first argument is the short form of the option, the second the long form, and any remaining lines are documentation to print in the help text. Given the above, any of these are acceptable:

$ ruby tutorial1.rb -c 2
$ ruby tutorial1.rb -c2
$ ruby tutorial1.rb --connections 2
$ ruby tutorial1.rb --connections=2
$ ruby tutorial1.rb --conn 2
uses_switch("-s", "--switch", string...)

Switches are almost like options, but they don't take arguments. If the switch is given, the user choice is "true". If its inverse (see below) is given, the choice is "false". Otherwise, the default is used. Here's a typical example:

  builder.add_choice(:ssh, :type=>:boolean, :default=>false) { | command_line |
    command_line.uses_switch("-s", "--ssh",
                             "Use ssh to open connection.")
  }

A user tells the program to use SSH like this:

$ ruby tutorial2.rb --ssh
SSH should be used.
$ ruby tutorial2.rb --s file
SSH should be used.

SSH is turned off like this:

$ ruby tutorial2.rb --no-ssh
SSH should not be used.

(Turning the switch off is pointless in this case, since the default is false.)

The documentation explains both options this way:

$ ruby tutorial2.rb --help
Usage: ruby tutorial2.rb [options]

Options:
-c, --connections COUNT     Number of connections to open.
-s, --[no-]ssh              Use ssh to open connection.
-?, -h, --help              Show this message.
uses_arglist

The command line argument list can be treated as another choice:

    builder.add_choice(:files) { | command_line |
      command_line.uses_arglist
    }
$ ruby tutorial3.rb arg1 arg2
SSH should not be used.
There are 19 connections.
{:files=>["arg1", "arg2"], :ssh=>false, :connections=>19}

What should happen if no command-line arguments are given? If that were to be treated as choosing the empty array, lesser priority sources could never affect the outcome. Since everything is typically of lower priority than the command line, that would make it pointless for a configuration file, say, ever to specify any values for the choice. So, instead, an empty argument list is treated just like a command-line option that's not mentioned: nothing is put into the @user-choices array.

As an example of how this works, consider this YAML file:

connections: 19
files:
   - one
   - two

Any command-line arguments will take precedence:

$ ruby tutorial3.rb cmd
SSH should not be used.
There are 19 connections.
{:files=>["cmd"], :ssh=>false, :connections=>19}

But an empty command line will yield to the YAML file:

$ ruby tutorial3.rb
SSH should not be used.
There are 19 connections.
{:files=>["one", "two"], :ssh=>false, :connections=>19}

There is no need to :type the choice as a [:string], since that's obvious from context. If you want to limit the length of the argument list, you can add a :length:

    builder.add_choice(:files, :length => 1..2) { | command_line |
      command_line.uses_arglist
    }
$ ruby tutorial4.rb 1 2 3
Error in the command line: 3 arguments given, 1 or 2 expected.
Usage: ruby tutorial4.rb [options] file1 [file2]

Options:
-c, --connections COUNT     Number of connections to open.
-s, --[no-]ssh              Use ssh to open connection.
-?, -h, --help              Show this message.

What happens in this case if the user gives no arguments on the command line, considering that the :length asks for one or two? It is not an error because the YAML file supplies two arguments:

$ ruby tutorial4.rb
SSH should not be used.
There are 19 connections.
{:files=>["one", "two"], :ssh=>false, :connections=>19}
uses_arg

Sometimes the argument list must have exactly one member. In that case, it's annoying to have to take that argument out of the array. users_arg does that for you, returning the single argument directly. Giving two or more arguments produces an error. If the user supplies no arguments on the command line, some other source must provide it. If no other source does, User-Choices raises a StandardError with a nice error message.

  def add_choices(builder)
    builder.add_choice(:infile) { | command_line |
      command_line.uses_arg
    }
  end
$ ruby tutorial5.rb 1
{:infile=>"1"}
$ ruby tutorial5.rb
Error in the command line: 0 arguments given, 1 expected.
Usage: ruby tutorial5.rb infile

Options:
-?, -h, --help   Show this message.
uses_optional_arg

This is like uses_arg except that it's OK for the user to provide no argument (either on the command line or some other source). In that case, the choice symbol doesn't appear as a key in the result.

  def add_choices(builder)
    builder.add_choice(:infile) { | command_line |
      command_line.uses_optional_arg
    }
  end
$ ruby tutorial6.rb
{}

4. Optionally touching up choices before execution

Consider this declaration:

  def add_sources(builder)
    builder.add_source(CommandLineSource, :usage,
                       "Usage: ruby #{$0} infile outfile")
  end
 
  def add_choices(builder)
    builder.add_choice(:files, :length => 2) { | command_line |
      command_line.uses_arglist
    }
  end

What you want is two @user_choices elements, something like @user_choices[:infile] and @user_choices[:outfile]. But there's no way to associate names with individual arglist elements, just with the whole thing.

The solution to the problem is a postprocessing step that allows you to modify @user_choices before your program starts its work. That's done like this:

  def postprocess_user_choices
    @user_choices[:infile] = @user_choices[:files][0]
    @user_choices[:outfile] = @user_choices[:files][1]
  end
$ ruby tutorial7.rb one two
{:outfile=>"two", :files=>["one", "two"], :infile=>"one"}

There's no need to call postprocess_user_choices yourself. It's done automatically.

5. Notes

  • Right now, a source can provide an element to @user_choices even if that element is never named as a choice in add_choices. I'm not sure if that's good, bad, or indifferent.