From a8de418f67bf38061795e089a9a42735d2f47bdc Mon Sep 17 00:00:00 2001 From: Yehuda Katz Date: Tue, 6 May 2008 19:21:22 -0700 Subject: [PATCH] Initial checkin of Hermes --- LICENSE | 20 ++++ README.markdown | 61 ++++++++++++ Rakefile | 37 +++++++ lib/getopt.rb | 238 ++++++++++++++++++++++++++++++++++++++++++++ lib/hermes.rb | 87 ++++++++++++++++ script/destroy | 14 +++ script/generate | 14 +++ spec/hermes_spec.rb | 88 ++++++++++++++++ spec/spec_helper.rb | 2 + 9 files changed, 561 insertions(+) create mode 100644 LICENSE create mode 100644 README.markdown create mode 100644 Rakefile create mode 100644 lib/getopt.rb create mode 100644 lib/hermes.rb create mode 100755 script/destroy create mode 100755 script/generate create mode 100644 spec/hermes_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..98722da45 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2008 Yehuda Katz + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.markdown b/README.markdown new file mode 100644 index 000000000..73ed85fdc --- /dev/null +++ b/README.markdown @@ -0,0 +1,61 @@ +hermes +====== + +Map options to a class. Simply create a class with the appropriate annotations, and have options automatically map +to functions and parameters. + +Examples: + + class MyApp + extend Hermes # [1] + + map "-L" => :list # [2] + + desc "install APP_NAME", "install one of the available apps" # [3] + method_options :force => :boolean # [4] + def install(name, opts) + ... code ... + if opts[:force] + # do something + end + end + + desc "list [SEARCH]", "list all of the available apps, limited by SEARCH" + def list(search = "") + # list everything + end + + end + + MyApp.start + +Hermes automatically maps commands as follows: + + app install name --force + +That gets converted to: + + MyApp.new.install("name", :force => true) + +[1] Use `extend Hermes` to turn a class into an option mapper + +[2] Map additional non-valid identifiers to specific methods. In this case, + convert -L to :list + +[3] Describe the method immediately below. The first parameter is the usage information, + and the second parameter is the description. + +[4] Provide any additional options. These will be marshaled from -- and - params. + In this case, a --force and a -f option is added. + +Types for `method_options` +-------------------------- + +
+
:boolean
+
true if the option is passed
+
:required
+
A key/value option that MUST be provided
+
:optional
+
A key/value option that MAY be provided
+
\ No newline at end of file diff --git a/Rakefile b/Rakefile new file mode 100644 index 000000000..89620c7f1 --- /dev/null +++ b/Rakefile @@ -0,0 +1,37 @@ +require 'rubygems' +require 'rake/gempackagetask' + +GEM = "hermes" +VERSION = "0.9.0" +AUTHOR = "Yehuda Katz" +EMAIL = "wycats@gmail.com" +HOMEPAGE = "http://yehudakatz.com" +SUMMARY = "A gem that maps options to a class" + +spec = Gem::Specification.new do |s| + s.name = GEM + s.version = VERSION + s.platform = Gem::Platform::RUBY + s.has_rdoc = true + s.extra_rdoc_files = ["README.markdown", "LICENSE"] + s.summary = SUMMARY + s.description = s.summary + s.author = AUTHOR + s.email = EMAIL + s.homepage = HOMEPAGE + + # Uncomment this to add a dependency + # s.add_dependency "foo" + + s.require_path = 'lib' + s.autorequire = GEM + s.files = %w(LICENSE README.markdown Rakefile) + Dir.glob("{lib,specs}/**/*") +end + +Rake::GemPackageTask.new(spec) do |pkg| + pkg.gem_spec = spec +end + +task :install => [:package] do + sh %{sudo gem install pkg/#{GEM}-#{VERSION}} +end \ No newline at end of file diff --git a/lib/getopt.rb b/lib/getopt.rb new file mode 100644 index 000000000..acbaf5f8d --- /dev/null +++ b/lib/getopt.rb @@ -0,0 +1,238 @@ +# The last time the Getopt gem was modified was August 2007, so it's safe to vendor (it does everything we need) + +module Getopt + + REQUIRED = 0 + BOOLEAN = 1 + OPTIONAL = 2 + INCREMENT = 3 + NEGATABLE = 4 + NUMERIC = 5 + + class Long + class Error < StandardError; end + + VERSION = '1.3.6' + + # Takes an array of switches. Each array consists of up to three + # elements that indicate the name and type of switch. Returns a hash + # containing each switch name, minus the '-', as a key. The value + # for each key depends on the type of switch and/or the value provided + # by the user. + # + # The long switch _must_ be provided. The short switch defaults to the + # first letter of the short switch. The default type is BOOLEAN. + # + # Example: + # + # opts = Getopt::Long.getopts( + # ["--debug"], + # ["--verbose", "-v"], + # ["--level", "-l", NUMERIC] + # ) + # + # See the README file for more information. + # + def self.getopts(*switches) + if switches.empty? + raise ArgumentError, "no switches provided" + end + + hash = {} # Hash returned to user + valid = [] # Tracks valid switches + types = {} # Tracks argument types + syns = {} # Tracks long and short arguments, or multiple shorts + + # If a string is passed, split it and convert it to an array of arrays + if switches.first.kind_of?(String) + switches = switches.join.split + switches.map!{ |switch| switch = [switch] } + end + + # Set our list of valid switches, and proper types for each switch + switches.each{ |switch| + valid.push(switch[0]) # Set valid long switches + + # Set type for long switch, default to BOOLEAN. + if switch[1].kind_of?(Fixnum) + switch[2] = switch[1] + types[switch[0]] = switch[2] + switch[1] = switch[0][1..2] + else + switch[2] ||= BOOLEAN + types[switch[0]] = switch[2] + switch[1] ||= switch[0][1..2] + end + + # Create synonym hash. Default to first char of long switch for + # short switch, e.g. "--verbose" creates a "-v" synonym. The same + # synonym can only be used once - first one wins. + syns[switch[0]] = switch[1] unless syns[switch[1]] + syns[switch[1]] = switch[0] unless syns[switch[1]] + + switch[1].each{ |char| + types[char] = switch[2] # Set type for short switch + valid.push(char) # Set valid short switches + } + + if ARGV.empty? && switch[2] == REQUIRED + raise Error, "no value provided for required argument '#{switch[0]}'" + end + } + + re_long = /^(--\w+[-\w+]*)?$/ + re_short = /^(-\w)$/ + re_long_eq = /^(--\w+[-\w+]*)?=(.*?)$|(-\w?)=(.*?)$/ + re_short_sq = /^(-\w)(\S+?)$/ + + ARGV.each_with_index{ |opt, index| + + # Allow either -x -v or -xv style for single char args + if re_short_sq.match(opt) + chars = opt.split("")[1..-1].map{ |s| s = "-#{s}" } + + chars.each_with_index{ |char, i| + unless valid.include?(char) + raise Error, "invalid switch '#{char}'" + end + + # Grab the next arg if the switch takes a required arg + if types[char] == REQUIRED + # Deal with a argument squished up against switch + if chars[i+1] + arg = chars[i+1..-1].join.tr("-","") + ARGV.push(char, arg) + break + else + arg = ARGV.delete_at(index+1) + if arg.nil? || valid.include?(arg) # Minor cheat here + err = "no value provided for required argument '#{char}'" + raise Error, err + end + ARGV.push(char, arg) + end + elsif types[char] == OPTIONAL + if chars[i+1] && !valid.include?(chars[i+1]) + arg = chars[i+1..-1].join.tr("-","") + ARGV.push(char, arg) + break + elsif + if ARGV[index+1] && !valid.include?(ARGV[index+1]) + arg = ARGV.delete_at(index+1) + ARGV.push(char, arg) + end + else + ARGV.push(char) + end + else + ARGV.push(char) + end + } + next + end + + if match = re_long.match(opt) || match = re_short.match(opt) + switch = match.captures.first + end + + if match = re_long_eq.match(opt) + switch, value = match.captures.compact + ARGV.push(switch, value) + next + end + + # Make sure that all the switches are valid. If 'switch' isn't + # defined at this point, it means an option was passed without + # a preceding switch, e.g. --option foo bar. + unless valid.include?(switch) + switch ||= opt + raise Error, "invalid switch '#{switch}'" + end + + # Required arguments + if types[switch] == REQUIRED + nextval = ARGV[index+1] + + # Make sure there's a value for mandatory arguments + if nextval.nil? + err = "no value provided for required argument '#{switch}'" + raise Error, err + end + + # If there is a value, make sure it's not another switch + if valid.include?(nextval) + err = "cannot pass switch '#{nextval}' as an argument" + raise Error, err + end + + # If the same option appears more than once, put the values + # in array. + if hash[switch] + hash[switch] = [hash[switch], nextval].flatten + else + hash[switch] = nextval + end + ARGV.delete_at(index+1) + end + + # For boolean arguments set the switch's value to true. + if types[switch] == BOOLEAN + if hash.has_key?(switch) + raise Error, "boolean switch already set" + end + hash[switch] = true + end + + # For increment arguments, set the switch's value to 0, or + # increment it by one if it already exists. + if types[switch] == INCREMENT + if hash.has_key?(switch) + hash[switch] += 1 + else + hash[switch] = 1 + end + end + + # For optional argument, there may be an argument. If so, it + # cannot be another switch. If not, it is set to true. + if types[switch] == OPTIONAL + nextval = ARGV[index+1] + if valid.include?(nextval) + hash[switch] = true + else + hash[switch] = nextval + ARGV.delete_at(index+1) + end + end + } + + # Set synonymous switches to the same value, e.g. if -t is a synonym + # for --test, and the user passes "--test", then set "-t" to the same + # value that "--test" was set to. + # + # This allows users to refer to the long or short switch and get + # the same value + hash.each{ |switch, val| + if syns.keys.include?(switch) + syns[switch].each{ |key| + hash[key] = val + } + end + } + + # Get rid of leading "--" and "-" to make it easier to reference + hash.each{ |key, value| + if key[0,2] == '--' + nkey = key.sub('--', '') + else + nkey = key.sub('-', '') + end + hash.delete(key) + hash[nkey] = value + } + + hash + end + + end +end diff --git a/lib/hermes.rb b/lib/hermes.rb new file mode 100644 index 000000000..71a8aff7b --- /dev/null +++ b/lib/hermes.rb @@ -0,0 +1,87 @@ +require "#{File.dirname(__FILE__)}/getopt" + +module Hermes + def self.extended(klass) + klass.class_eval <<-RUBY, "class_cli.rb", 6 + + def self.method_added(meth) + return if !public_instance_methods.include?(meth.to_s) || !@@usage + @@descriptions = defined?(@@descriptions) ? @@descriptions : [] + @@usages = defined?(@@usages) ? @@usages : [] + @@opts = defined?(@@opts) ? @@opts : [] + @@descriptions << [meth.to_s, @@desc] + @@usages << [meth.to_s, @@usage] + if defined?(@@method_options) && @@method_options + @@opts << [meth.to_s, @@method_options] + end + @@usage, @@desc, @@method_options = nil + end + + def self.map(map) + @@map = map + end + + def self.desc(usage, description) + @@usage, @@desc = usage, description + end + + def self.method_options(opts) + @@method_options = opts.inject({}) do |accum, (k,v)| + accum.merge("--" + k.to_s => v.to_s.upcase) + end + end + + def self.start + meth = ARGV.shift + params = [] + while !ARGV.empty? + break if ARGV.first =~ /^\-/ + params << ARGV.shift + end + if defined?(@@map) && @@map[meth] + meth = @@map[meth].to_s + end + if @@opts.assoc(meth) + opts = @@opts.assoc(meth).last.map {|opt, val| [opt, val == true ? Getopt::BOOLEAN : Getopt.const_get(val)].flatten} + options = Getopt::Long.getopts(*opts) + params << options + end + new(meth, params).instance_variable_get("@results") + end + + def initialize(op, params) + @results = send(op.to_sym, *params) if public_methods.include?(op) + end + + private + def format_opts(opts) + return "" unless opts + opts.map do |opt, val| + if val == true || val == "BOOLEAN" + opt + elsif val == "REQUIRED" + opt + "=" + opt.gsub(/\-/, "").upcase + elsif val == "OPTIONAL" + "[" + opt + "=" + opt.gsub(/\-/, "").upcase + "]" + end + end.join(" ") + end + + public + desc "help", "show this screen" + def help + puts "Options" + puts "-------" + max_usage = @@usages.max {|x,y| x.last.to_s.size <=> y.last.to_s.size}.last.size + max_opts = @@opts.empty? ? 0 : format_opts(@@opts.max {|x,y| x.last.to_s.size <=> y.last.to_s.size}.last).size + max_desc = @@descriptions.max {|x,y| x.last.to_s.size <=> y.last.to_s.size}.last.size + @@usages.each do |meth, usage| + format = "%-" + (max_usage + max_opts + 4).to_s + "s" + print format % (@@usages.assoc(meth)[1] + (@@opts.assoc(meth) ? " " + format_opts(@@opts.assoc(meth)[1]) : "")) + # print format % (@@usages.assoc(meth)[1] + @@opts.assoc(meth) ? format_opts(@@opts.assoc(meth)[1]) : "")) + puts @@descriptions.assoc(meth)[1] + end + end + RUBY + end +end diff --git a/script/destroy b/script/destroy new file mode 100755 index 000000000..40901a89e --- /dev/null +++ b/script/destroy @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby +APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..')) + +begin + require 'rubigen' +rescue LoadError + require 'rubygems' + require 'rubigen' +end +require 'rubigen/scripts/destroy' + +ARGV.shift if ['--help', '-h'].include?(ARGV[0]) +RubiGen::Base.use_component_sources! [:newgem_simple, :test_unit] +RubiGen::Scripts::Destroy.new.run(ARGV) diff --git a/script/generate b/script/generate new file mode 100755 index 000000000..5c8ed0111 --- /dev/null +++ b/script/generate @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby +APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..')) + +begin + require 'rubigen' +rescue LoadError + require 'rubygems' + require 'rubigen' +end +require 'rubigen/scripts/generate' + +ARGV.shift if ['--help', '-h'].include?(ARGV[0]) +RubiGen::Base.use_component_sources! [:newgem_simple, :test_unit] +RubiGen::Scripts::Generate.new.run(ARGV) diff --git a/spec/hermes_spec.rb b/spec/hermes_spec.rb new file mode 100644 index 000000000..c48b172cb --- /dev/null +++ b/spec/hermes_spec.rb @@ -0,0 +1,88 @@ +require File.dirname(__FILE__) + '/spec_helper' +require "hermes" + +class MyApp + extend Hermes + + map "-T" => :animal + + desc "zoo", "zoo around" + def zoo + true + end + + desc "animal TYPE", "horse around" + def animal(type) + [type] + end + + desc "foo BAR", "do some fooing" + method_options :force => :boolean + def foo(bar, opts) + [bar, opts] + end + + desc "bar BAZ BAT", "do some barring" + method_options :option1 => :required + def bar(baz, bat, opts) + [baz, bat, opts] + end + + desc "baz BAT", "do some bazzing" + method_options :option1 => :optional + def baz(bat, opts) + [bat, opts] + end +end + +describe "hermes" do + it "calls a no-param method when no params are passed" do + ARGV.replace ["zoo"] + MyApp.start.should == true + end + + it "calls a single-param method when a single param is passed" do + ARGV.replace ["animal", "fish"] + MyApp.start.should == ["fish"] + end + + it "calls the alias of a method if one is provided via .map" do + ARGV.replace ["-T", "fish"] + MyApp.start.should == ["fish"] + end + + it "raises an error if a required param is not provided" do + ARGV.replace ["animal"] + lambda { MyApp.start }.should raise_error(ArgumentError) + end + + it "calls a method with an optional boolean param when the param is passed" do + ARGV.replace ["foo", "one", "--force"] + MyApp.start.should == ["one", {"force" => true, "f" => true}] + end + + it "calls a method with an optional boolean param when the param is not passed" do + ARGV.replace ["foo", "one"] + MyApp.start.should == ["one", {}] + end + + it "calls a method with a required key/value param" do + ARGV.replace ["bar", "one", "two", "--option1", "hello"] + MyApp.start.should == ["one", "two", {"option1" => "hello", "o" => "hello"}] + end + + it "errors out when a required key/value option is not passed" do + ARGV.replace ["bar", "one", "two"] + lambda { MyApp.start }.should raise_error(Getopt::Long::Error) + end + + it "calls a method with an optional key/value param" do + ARGV.replace ["baz", "one", "--option1", "hello"] + MyApp.start.should == ["one", {"option1" => "hello", "o" => "hello"}] + end + + it "calls a method with an empty Hash for options if an optional key/value param is not provided" do + ARGV.replace ["baz", "one"] + MyApp.start.should == ["one", {}] + end +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 000000000..8eee1f2a5 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,2 @@ +$TESTING=true +$:.push File.join(File.dirname(__FILE__), '..', 'lib')