Skip to content

Instantly share code, notes, and snippets.

@jdsumsion
Created January 16, 2012 16:18
Show Gist options
  • Save jdsumsion/1621592 to your computer and use it in GitHub Desktop.
Save jdsumsion/1621592 to your computer and use it in GitHub Desktop.
Argable example

The whole intent of this gist is subsumed by this awesome-looking gem:

The following things don't appear to be supported by cli yet (as of 24 Jan. 2012):

  1. multiple short options like "-rf"
  2. git-like concatenated option/value combinations like "-Spattern"
  3. any kind of sub-command support (not supported by this gist either)

Other than that, cli is everything I wanted and more.

$:.unshift "."
require 'argable'
class ExampleCLI
include Argable
def initialize
initialize_options(
"message_m" => [ nil, :required_value ],
"license_l" => "CC-BY-SA-3.0",
"version_v" => false,
"help_h" => false,
)
end
def run situation, argv
puts situation
puts situation.gsub /./, "="
puts "cli: %s" % argv.inspect
options, args = parse argv
puts "options: %s" % options.inspect
puts "args: %s" % args.inspect
puts
end
def run_examples
run "Example with NO args", []
run "Example of help (long)", %w{ --help }
run "Example of version (short)", %w{ -v }
run "Example of license (override)", %w{ --license CC-BY-3.0 }
run "Example of message (required value)", [ "-m", "Here is a commit message" ]
run "Example of args & options", %w{ --license CC-BY-3.0 new-repo }
end
end
ExampleCLI.new.run_examples
__END__
Example with NO args
====================
cli: []
options: {:license=>"CC-BY-SA-3.0", :version=>false, :help=>false}
args: []
Example of help (long)
======================
cli: ["--help"]
options: {:license=>"CC-BY-SA-3.0", :version=>false, :help=>true}
args: []
Example of version (short)
==========================
cli: ["-v"]
options: {:license=>"CC-BY-SA-3.0", :version=>true, :help=>false}
args: []
Example of license (override)
=============================
cli: ["--license", "CC-BY-3.0"]
options: {:license=>"CC-BY-3.0", :version=>false, :help=>false}
args: []
Example of message (required value)
===================================
cli: ["-m", "Here is a commit message"]
options: {:license=>"CC-BY-SA-3.0", :version=>false, :help=>false, :message=>"Here is a commit message"}
args: []
Example of args & options
=========================
cli: ["--license", "CC-BY-3.0", "new-repo"]
options: {:license=>"CC-BY-3.0", :version=>false, :help=>false}
args: ["new-repo"]
require 'forwardable'
module Argable
def initialize_options options_spec
@arg_parser = ArgParser.new options_spec
end
extend Forwardable
def_delegator :@arg_parser, :parse_initial_options
def_delegator :@arg_parser, :parse
class ArgParser
OPTION_PATTERN = /^--?(.+)/
attr_reader :defaults
# Initializes the +options+ hash by merging an entry for each option like
# +{ :verbose[_v] => false }+ or +{ :verbose[_v] => [ nil, lambda ] }+ or
# +{ :verbose[_v] => [ default, :required_value, :required_option, lambda ] }+
# with extra entries keyed under the short option name (like :v for
# :verbose).
def initialize options_spec
opts = {}
short_opts = {}
options_spec.each_entry do |key, *values|
if key =~ /^([^_]+)_(.)$/ then
opts[$1.to_sym] =
short_opts[$2.to_sym] = Option.new *[ $1, values ].flatten
else
opts[key.to_sym] = Option.new *[ key.to_s, values ].flatten
end
end
@options = opts
@defaults = calculate_defaults
# merge the short opts in after calculating defaults to avoid duplicate
# entries in the defaults hash (known by long AND short keys)
@options_with_short_keys = @options.merge short_opts
end
# Parses +argv+ up to the first non-option arg.
# returns [ options hash, remaining args ]
def parse_initial_options argv
parse_args argv do |argv, remaining_args, arg|
argv.unshift arg
:break
end
end
# Parses +argv+ entirely.
# returns [ options hash, all non-option args ]
def parse argv
parse_args argv do |argv, remaining_args, arg|
remaining_args << arg
end
end
private
def parse_args argv
opts = @defaults.dup
remaining_args = []
# handle all the args (including any that get unshifted back on)
argv = argv.to_a.dup
while arg = argv.shift do
# early out when the user says so
break if arg == "--"
if arg =~ OPTION_PATTERN
# look for a default value
opt = $1.to_sym
# detect:
# 1) multiple short options in a row (handle recursively), or
# 2) short option followed immediately by value
# 3) long option with value "--opt=val" => "--opt" "val"
if !valid_option? opt
case arg
when /^-([^-])(.+)/ then unshift_split_options argv, $1, $2
when /^--([^=]+)=(.+)/ then unshift_option_value argv, $1, $2
else raise ArgParseException, nil, "unknown option: " + arg
end
next
end
# handle boolean option
if boolean_option? opt
opts[canonical_option_sym opt] = true
else
# handle option with value
if argv.first and argv.first !~ OPTION_PATTERN
opts[canonical_option_sym opt] = argv.shift
# or blow up if no value is available
elsif required_value? opt
raise ArgParseException, opt
else
raise RuntimeError, opt, "expecting default value to be set already" unless !opt[canonical_option_sym opt]
end
end
else # not an option argument
break if :break == yield(argv, remaining_args, arg)
end
end
remaining_args.concat argv
each_option do |opt_sym, opt|
if !opts.has_key? opt_sym
raise ArgParseException, "required option: %s" % [opt_sym] if opt.required_option?
raise RuntimeError, "default not pre-populated for option: %s" % [opt_sym] unless opt.required_value?
end
end
[ opts, remaining_args ]
end
# helper method that re-adds split up short options to the start of +argv+
# for further handling
def unshift_split_options argv, initial_boolean_option, remaining_str
if boolean_option? initial_boolean_option.to_sym
# boolean option, so put remaining on as option(s)
argv.unshift "-" + remaining_str
else
# not a boolean option, so put remaining on as an arg
argv.unshift remaining_str
end
# recurse to handle option through regular code paths
argv.unshift "-" + initial_boolean_option
end
# helper method that re-adds split up --long=value option to the start of
# +argv+ for further handling
def unshift_option_value argv, initial_option, value_str
# recurse to handle option through regular code paths
argv.unshift "--" + initial_option
argv.unshift value_str
end
# runs through canonical +options+ and pre-calculates the defaults hash
def calculate_defaults
# see http://www.ruby-forum.com/topic/185611#811055 for the source of
# this obtuseness (basically, I wanted Hash.map, but it didn't work)
opts_with_defaults = @options.select {|_, opt| opt.should_have_default_value? }
opts_with_defaults.merge(opts_with_defaults) {|_, opt, _| opt.default_value }
end
# yields each canonical +Option+ instance to the supplied block
def each_option
@options.each {|opt_sym, opt| yield opt_sym, opt }
end
# returns true if the option is a valid one
def valid_option? opt_sym
@options_with_short_keys.key? opt_sym
end
# returns true if the value for +opt_sym+ is a boolean
def boolean_option? opt_sym
val = default_value opt_sym
!!val == val
end
# returns true if the option for +opt_sym+ has a +:required_value+
def required_value? opt_sym
@options_with_short_keys[opt_sym].required_value?
end
# returns the option's default value
def default_value opt_sym
@options_with_short_keys[opt_sym].default_value
end
# returns the option's full name (as a symbol)
def canonical_option_sym opt_sym
@options_with_short_keys[opt_sym].name.to_sym
end
end
class Option
attr_accessor :ary
# initializes a new +Option+
def initialize name=nil, default_value=nil, *args
block = nil
args.reject! {|arg| block = arg if arg.kind_of? Proc}
@ary = [ name, default_value, *args, block ]
if !default_value.nil? != should_have_default_value?
raise ArgumentError, "option: %s should specify a default value OR :required_value or :required_option, but NOT BOTH" % [name]
end
end
def name; @ary[0]; end
def default_value; @ary[1]; end
def should_have_default_value?; !required_value? and !required_option? end
def required_value?; @ary.include? :required_value; end
def required_option?; @ary.include? :required_option; end
def parse! arg; @ary[-1].call arg; end
end
class ArgParseException < RuntimeError
attr_accessor :option
def initialize option=nil, msg=nil
@option = option
if option
super "error parsing option: %s#{" - " + msg if msg}" % [option]
else
super msg
end
end
end
end
require './argable'
require 'test/unit'
class ArgableTest < Test::Unit::TestCase
def test_parses_initial_boolean_option
a = Argable::ArgParser.new :verbose_v => true
options, args = a.parse_initial_options %w{ --verbose arg1 --verbose }
assert options[:verbose]
assert args == [ "arg1", "--verbose" ]
end
def test_parses_initial_short_option
a = Argable::ArgParser.new :verbose_v => true
options, args = a.parse_initial_options %w{ -v arg1 -v }
assert options[:verbose]
assert args == [ "arg1", "-v" ]
end
def test_parses_initial_multiple_short_options
a = Argable::ArgParser.new :recurse_r => false, :force_f => false
options, args = a.parse_initial_options %w{ -rf arg1 -rf }
assert options[:recurse]
assert options[:force]
assert args == [ "arg1", "-rf" ]
end
def test_parses_initial_options_stops
a = Argable::ArgParser.new :force_f => false
options, args = a.parse_initial_options %w{ -- -rf arg1 -rf }
assert false == options[:force]
assert args == [ "-rf", "arg1", "-rf" ]
end
def test_parses_initial_option_with_value
a = Argable::ArgParser.new :file_f => [ nil, :required_value ]
options, args = a.parse_initial_options %w{ --file abc arg }
assert "abc" == options[:file]
assert args == [ "arg" ]
end
def test_parses_initial_short_option_with_value
a = Argable::ArgParser.new :file_f => [ nil, :required_value ]
options, args = a.parse_initial_options %w{ -f abc arg }
assert "abc" == options[:file]
assert args == [ "arg" ]
end
def test_parses_initial_ambiguous_value
a = Argable::ArgParser.new :search_S => [ nil, :required_value ], :recurse_r => false
options, args = a.parse_initial_options %w{ -Sr arg }
assert "r" == options[:search]
assert false == options[:recurse]
assert args == [ "arg" ]
end
def test_parse_fails_without_required_option
assert_raise Argable::ArgParseException do
a = Argable::ArgParser.new :version_v => [ nil, :required_option ]
options, args = a.parse_initial_options []
raise "should have failed without required option"
end
end
def test_parse_fails_without_required_value
assert_raise Argable::ArgParseException do
a = Argable::ArgParser.new :file_f => [ nil, :required_value ]
options, args = a.parse_initial_options %w{ -f }
raise "should have failed without required value"
end
end
def test_parses_non_sym_option
a = Argable::ArgParser.new "v" => true
options, args = a.parse_initial_options %w{ -v }
assert options[:v]
end
def test_parses_enum
a = Argable::ArgParser.new :verbose_v => true
options, _ = a.parse_initial_options %w{ -v }.each
assert options[:verbose]
end
def test_default_value
a = Argable::ArgParser.new :verbose_v => true
options, _ = a.parse_initial_options []
assert options[:verbose]
end
def test_instantiate_requires_option_template
assert_raise ArgumentError do
Argable::ArgParser.new
end
end
def test_instantiate_with_option_template
assert_nothing_raised ArgumentError do
Argable::ArgParser.new Hash.new
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment