diff --git a/lib/puppet/string.rb b/lib/puppet/string.rb index 04db1f33b..517cf4506 100644 --- a/lib/puppet/string.rb +++ b/lib/puppet/string.rb @@ -1,100 +1,104 @@ require 'puppet' require 'puppet/util/autoload' class Puppet::String - require 'puppet/string/action_manager' require 'puppet/string/string_collection' + require 'puppet/string/action_manager' include Puppet::String::ActionManager extend Puppet::String::ActionManager + require 'puppet/string/option_manager' + include Puppet::String::OptionManager + extend Puppet::String::OptionManager + include Puppet::Util class << self # This is just so we can search for actions. We only use its # list of directories to search. # Can't we utilize an external autoloader, or simply use the $LOAD_PATH? -pvb def autoloader @autoloader ||= Puppet::Util::Autoload.new(:application, "puppet/string") end def strings Puppet::String::StringCollection.strings end def string?(name, version) Puppet::String::StringCollection.string?(name, version) end def register(instance) Puppet::String::StringCollection.register(instance) end def define(name, version, &block) if string?(name, version) string = Puppet::String::StringCollection[name, version] else string = self.new(name, version) Puppet::String::StringCollection.register(string) string.load_actions end string.instance_eval(&block) if block_given? return string end alias :[] :define end attr_accessor :default_format def set_default_format(format) self.default_format = format.to_sym end attr_accessor :type, :verb, :version, :arguments attr_reader :name def initialize(name, version, &block) unless Puppet::String::StringCollection.validate_version(version) - raise ArgumentError, "Cannot create string with invalid version number '#{version}'!" + raise ArgumentError, "Cannot create string #{name.inspect} with invalid version number '#{version}'!" end @name = Puppet::String::StringCollection.underscorize(name) @version = version @default_format = :pson instance_eval(&block) if block_given? end # Try to find actions defined in other files. def load_actions path = "puppet/string/#{name}" loaded = [] [path, "#{name}@#{version}/#{path}"].each do |path| Puppet::String.autoloader.search_directories.each do |dir| fdir = ::File.join(dir, path) next unless FileTest.directory?(fdir) Dir.chdir(fdir) do Dir.glob("*.rb").each do |file| aname = file.sub(/\.rb/, '') if loaded.include?(aname) Puppet.debug "Not loading duplicate action '#{aname}' for '#{name}' from '#{fdir}/#{file}'" next end loaded << aname Puppet.debug "Loading action '#{aname}' for '#{name}' from '#{fdir}/#{file}'" require "#{Dir.pwd}/#{aname}" end end end end end def to_s "Puppet::String[#{name.inspect}, #{version.inspect}]" end end diff --git a/lib/puppet/string/action.rb b/lib/puppet/string/action.rb index 5a7f3f203..4219aca0a 100644 --- a/lib/puppet/string/action.rb +++ b/lib/puppet/string/action.rb @@ -1,30 +1,52 @@ require 'puppet/string' +require 'puppet/string/option' class Puppet::String::Action attr_reader :name def to_s "#{@string}##{@name}" end def initialize(string, name, attrs = {}) - name = name.to_s - raise "'#{name}' is an invalid action name" unless name =~ /^[a-z]\w*$/ - - @string = string - @name = name + raise "#{name.inspect} is an invalid action name" unless name.to_s =~ /^[a-z]\w*$/ + @string = string + @name = name.to_sym + @options = {} attrs.each do |k,v| send("#{k}=", v) end end def invoke(*args, &block) @string.method(name).call(*args,&block) end def invoke=(block) if @string.is_a?(Class) @string.define_method(@name, &block) else @string.meta_def(@name, &block) end end + + def add_option(option) + if option? option.name then + raise ArgumentError, "#{option.name} duplicates an existing option on #{self}" + elsif @string.option? option.name then + raise ArgumentError, "#{option.name} duplicates an existing option on #{@string}" + end + + @options[option.name] = option + end + + def option?(name) + @options.include? name.to_sym + end + + def options + (@options.keys + @string.options).sort + end + + def get_option(name) + @options[name.to_sym] || @string.get_option(name) + end end diff --git a/lib/puppet/string/action_builder.rb b/lib/puppet/string/action_builder.rb index b3db51104..fb2a749ae 100644 --- a/lib/puppet/string/action_builder.rb +++ b/lib/puppet/string/action_builder.rb @@ -1,27 +1,30 @@ require 'puppet/string' require 'puppet/string/action' class Puppet::String::ActionBuilder attr_reader :action def self.build(string, name, &block) - name = name.to_s - raise "Action '#{name}' must specify a block" unless block - builder = new(string, name, &block) - builder.action + raise "Action #{name.inspect} must specify a block" unless block + new(string, name, &block).action end def initialize(string, name, &block) @string = string @action = Puppet::String::Action.new(string, name) instance_eval(&block) end # Ideally the method we're defining here would be added to the action, and a # method on the string would defer to it, but we can't get scope correct, # so we stick with this. --daniel 2011-03-24 def invoke(&block) raise "Invoke called on an ActionBuilder with no corresponding Action" unless @action @action.invoke = block end + + def option(name, attrs = {}, &block) + option = Puppet::String::OptionBuilder.build(@action, name, attrs, &block) + @action.add_option(option) + end end diff --git a/lib/puppet/string/action_manager.rb b/lib/puppet/string/action_manager.rb index c29dbf454..c980142ce 100644 --- a/lib/puppet/string/action_manager.rb +++ b/lib/puppet/string/action_manager.rb @@ -1,45 +1,41 @@ require 'puppet/string/action_builder' module Puppet::String::ActionManager # Declare that this app can take a specific action, and provide # the code to do so. def action(name, &block) @actions ||= {} - name = name.to_s.downcase.to_sym - raise "Action #{name} already defined for #{self}" if action?(name) - action = Puppet::String::ActionBuilder.build(self, name, &block) - - @actions[name] = action + @actions[action.name] = action end # This is the short-form of an action definition; it doesn't use the # builder, just creates the action directly from the block. def script(name, &block) @actions ||= {} - name = name.to_s.downcase.to_sym raise "Action #{name} already defined for #{self}" if action?(name) @actions[name] = Puppet::String::Action.new(self, name, :invoke => block) end def actions @actions ||= {} result = @actions.keys if self.is_a?(Class) and superclass.respond_to?(:actions) result += superclass.actions elsif self.class.respond_to?(:actions) result += self.class.actions end result.sort end def get_action(name) - @actions[name].dup + @actions ||= {} + @actions[name.to_sym] end def action?(name) actions.include?(name.to_sym) end end diff --git a/lib/puppet/string/option.rb b/lib/puppet/string/option.rb new file mode 100644 index 000000000..bdc3e07c5 --- /dev/null +++ b/lib/puppet/string/option.rb @@ -0,0 +1,24 @@ +class Puppet::String::Option + attr_reader :name, :string + + def initialize(string, name, attrs = {}) + raise "#{name.inspect} is an invalid option name" unless name.to_s =~ /^[a-z]\w*$/ + @string = string + @name = name.to_sym + attrs.each do |k,v| send("#{k}=", v) end + end + + def to_s + @name.to_s.tr('_', '-') + end + + Types = [:boolean, :string] + def type + @type ||= :boolean + end + def type=(input) + value = begin input.to_sym rescue nil end + Types.include?(value) or raise ArgumentError, "#{input.inspect} is not a valid type" + @type = value + end +end diff --git a/lib/puppet/string/option_builder.rb b/lib/puppet/string/option_builder.rb new file mode 100644 index 000000000..2087cbc99 --- /dev/null +++ b/lib/puppet/string/option_builder.rb @@ -0,0 +1,25 @@ +require 'puppet/string/option' + +class Puppet::String::OptionBuilder + attr_reader :option + + def self.build(string, name, attrs = {}, &block) + new(string, name, attrs, &block).option + end + + private + def initialize(string, name, attrs, &block) + @string = string + @option = Puppet::String::Option.new(string, name, attrs) + block and instance_eval(&block) + @option + end + + # Metaprogram the simple DSL from the option class. + Puppet::String::Option.instance_methods.grep(/=$/).each do |setter| + next if setter =~ /^=/ # special case, darn it... + + dsl = setter.sub(/=$/, '') + define_method(dsl) do |value| @option.send(setter, value) end + end +end diff --git a/lib/puppet/string/option_manager.rb b/lib/puppet/string/option_manager.rb new file mode 100644 index 000000000..df3ae6b4b --- /dev/null +++ b/lib/puppet/string/option_manager.rb @@ -0,0 +1,46 @@ +require 'puppet/string/option_builder' + +module Puppet::String::OptionManager + # Declare that this app can take a specific option, and provide + # the code to do so. + def option(name, attrs = {}, &block) + @options ||= {} + raise ArgumentError, "Option #{name} already defined for #{self}" if option?(name) + actions.each do |action| + if get_action(action).option?(name) then + raise ArgumentError, "Option #{name} already defined on action #{action} for #{self}" + end + end + option = Puppet::String::OptionBuilder.build(self, name, &block) + @options[option.name] = option + end + + def options + @options ||= {} + result = @options.keys + + if self.is_a?(Class) and superclass.respond_to?(:options) + result += superclass.options + elsif self.class.respond_to?(:options) + result += self.class.options + end + result.sort + end + + def get_option(name) + @options ||= {} + result = @options[name.to_sym] + unless result then + if self.is_a?(Class) and superclass.respond_to?(:get_option) + result = superclass.get_option(name) + elsif self.class.respond_to?(:get_option) + result = self.class.get_option(name) + end + end + return result + end + + def option?(name) + options.include? name.to_sym + end +end diff --git a/spec/unit/string/action_builder_spec.rb b/spec/unit/string/action_builder_spec.rb index c3395cf6a..946244cbf 100755 --- a/spec/unit/string/action_builder_spec.rb +++ b/spec/unit/string/action_builder_spec.rb @@ -1,30 +1,59 @@ #!/usr/bin/env ruby require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper.rb') require 'puppet/string/action_builder' describe Puppet::String::ActionBuilder do describe "::build" do it "should build an action" do action = Puppet::String::ActionBuilder.build(nil,:foo) do end action.should be_a(Puppet::String::Action) - action.name.should == "foo" + action.name.should == :foo end it "should define a method on the string which invokes the action" do string = Puppet::String.new(:action_builder_test_string, '0.0.1') action = Puppet::String::ActionBuilder.build(string, :foo) do invoke do "invoked the method" end end string.foo.should == "invoked the method" end it "should require a block" do - lambda { Puppet::String::ActionBuilder.build(nil,:foo) }.should raise_error("Action 'foo' must specify a block") + lambda { Puppet::String::ActionBuilder.build(nil,:foo) }. + should raise_error("Action :foo must specify a block") + end + + describe "when handling options" do + let :string do Puppet::String.new(:option_handling, '0.0.1') end + + it "should have a #option DSL function" do + method = nil + Puppet::String::ActionBuilder.build(string, :foo) do + method = self.method(:option) + end + method.should be + end + + it "should define an option without a block" do + action = Puppet::String::ActionBuilder.build(string, :foo) do + option :bar + end + action.should be_option :bar + end + + it "should accept an empty block" do + action = Puppet::String::ActionBuilder.build(string, :foo) do + option :bar do + # This space left deliberately blank. + end + end + action.should be_option :bar + end end end end diff --git a/spec/unit/string/action_spec.rb b/spec/unit/string/action_spec.rb index f4ca8316d..d182f0abe 100755 --- a/spec/unit/string/action_spec.rb +++ b/spec/unit/string/action_spec.rb @@ -1,66 +1,148 @@ #!/usr/bin/env ruby require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper.rb') require 'puppet/string/action' describe Puppet::String::Action do describe "when validating the action name" do [nil, '', 'foo bar', '-foobar'].each do |input| it "should treat #{input.inspect} as an invalid name" do expect { Puppet::String::Action.new(nil, input) }. should raise_error(/is an invalid action name/) end end end describe "when invoking" do it "should be able to call other actions on the same object" do string = Puppet::String.new(:my_string, '0.0.1') do action(:foo) do invoke { 25 } end action(:bar) do invoke { "the value of foo is '#{foo}'" } end end string.foo.should == 25 string.bar.should == "the value of foo is '25'" end # bar is a class action calling a class action # quux is a class action calling an instance action # baz is an instance action calling a class action # qux is an instance action calling an instance action it "should be able to call other actions on the same object when defined on a class" do class Puppet::String::MyStringBaseClass < Puppet::String action(:foo) do invoke { 25 } end action(:bar) do invoke { "the value of foo is '#{foo}'" } end action(:quux) do invoke { "qux told me #{qux}" } end end string = Puppet::String::MyStringBaseClass.new(:my_inherited_string, '0.0.1') do action(:baz) do invoke { "the value of foo in baz is '#{foo}'" } end action(:qux) do invoke { baz } end end string.foo.should == 25 string.bar.should == "the value of foo is '25'" string.quux.should == "qux told me the value of foo in baz is '25'" string.baz.should == "the value of foo in baz is '25'" string.qux.should == "the value of foo in baz is '25'" end end + + describe "with action-level options" do + it "should support options without arguments" do + string = Puppet::String.new(:action_level_options, '0.0.1') do + action(:foo) do + option :bar + end + end + + string.should_not be_option :bar + string.get_action(:foo).should be_option :bar + end + + it "should support options with an empty block" do + string = Puppet::String.new(:action_level_options, '0.0.1') do + action :foo do + option :bar do + # this line left deliberately blank + end + end + end + + string.should_not be_option :bar + string.get_action(:foo).should be_option :bar + end + + it "should return only action level options when there are no string options" do + string = Puppet::String.new(:action_level_options, '0.0.1') do + action :foo do option :bar end + end + + string.get_action(:foo).options.should =~ [:bar] + end + + describe "with both string and action options" do + let :string do + Puppet::String.new(:action_level_options, '0.0.1') do + action :foo do option :bar end + action :baz do option :bim end + option :quux + end + end + + it "should return combined string and action options" do + string.get_action(:foo).options.should =~ [:bar, :quux] + end + + it "should get an action option when asked" do + string.get_action(:foo).get_option(:bar). + should be_an_instance_of Puppet::String::Option + end + + it "should get a string option when asked" do + string.get_action(:foo).get_option(:quux). + should be_an_instance_of Puppet::String::Option + end + + it "should return options only for this action" do + string.get_action(:baz).options.should =~ [:bim, :quux] + end + end + + it "should fail when a duplicate option is added" do + expect { + Puppet::String.new(:action_level_options, '0.0.1') do + action :foo do + option :foo + option :foo + end + end + }.should raise_error ArgumentError, /foo duplicates an existing option/ + end + + it "should fail when a string option duplicates an action option" do + expect { + Puppet::String.new(:action_level_options, '0.0.1') do + option :foo + action :bar do option :foo end + end + }.should raise_error ArgumentError, /duplicates an existing option .*action_level/i + end + end end diff --git a/spec/unit/string/option_builder_spec.rb b/spec/unit/string/option_builder_spec.rb new file mode 100644 index 000000000..685787808 --- /dev/null +++ b/spec/unit/string/option_builder_spec.rb @@ -0,0 +1,57 @@ +require 'puppet/string/option_builder' + +describe Puppet::String::OptionBuilder do + let :string do Puppet::String.new(:option_builder_testing, '0.0.1') end + + it "should be able to construct an option without a block" do + Puppet::String::OptionBuilder.build(string, :foo). + should be_an_instance_of Puppet::String::Option + end + + it "should set attributes during construction" do + # Walk all types, since at least one of them should be non-default... + Puppet::String::Option::Types.each do |type| + option = Puppet::String::OptionBuilder.build(string, :foo, :type => type) + option.should be_an_instance_of Puppet::String::Option + option.type.should == type + end + end + + describe "when using the DSL block" do + it "should work with an empty block" do + option = Puppet::String::OptionBuilder.build(string, :foo) do + # This block deliberately left blank. + end + + option.should be_an_instance_of Puppet::String::Option + end + + describe "#type" do + Puppet::String::Option::Types.each do |valid| + it "should accept #{valid.inspect}" do + option = Puppet::String::OptionBuilder.build(string, :foo) do + type valid + end + option.should be_an_instance_of Puppet::String::Option + end + + it "should accept #{valid.inspect} as a string" do + option = Puppet::String::OptionBuilder.build(string, :foo) do + type valid.to_s + end + option.should be_an_instance_of Puppet::String::Option + end + + [:foo, nil, true, false, 12, '12', 'whatever', ::String, URI].each do |input| + it "should reject #{input.inspect}" do + expect { + Puppet::String::OptionBuilder.build(string, :foo) do + type input + end + }.should raise_error ArgumentError, /not a valid type/ + end + end + end + end + end +end diff --git a/spec/unit/string/option_spec.rb b/spec/unit/string/option_spec.rb new file mode 100644 index 000000000..9bb4309cd --- /dev/null +++ b/spec/unit/string/option_spec.rb @@ -0,0 +1,61 @@ +require 'puppet/string/option' + +describe Puppet::String::Option do + let :string do Puppet::String.new(:option_testing, '0.0.1') end + + it "requires a string when created" do + expect { Puppet::String::Option.new }. + should raise_error ArgumentError, /wrong number of arguments/ + end + + it "also requires a name when created" do + expect { Puppet::String::Option.new(string) }. + should raise_error ArgumentError, /wrong number of arguments/ + end + + it "should create an instance when given a string and name" do + Puppet::String::Option.new(string, :foo). + should be_instance_of Puppet::String::Option + end + + describe "#to_s" do + it "should transform a symbol into a string" do + Puppet::String::Option.new(string, :foo).to_s.should == "foo" + end + + it "should use - rather than _ to separate words" do + Puppet::String::Option.new(string, :foo_bar).to_s.should == "foo-bar" + end + end + + describe "#type" do + Puppet::String::Option::Types.each do |type| + it "should accept #{type.inspect}" do + Puppet::String::Option.new(string, :foo, :type => type). + should be_an_instance_of Puppet::String::Option + end + + it "should accept #{type.inspect} when given as a string" do + Puppet::String::Option.new(string, :foo, :type => type.to_s). + should be_an_instance_of Puppet::String::Option + end + end + + [:foo, nil, true, false, 12, '12', 'whatever', ::String, URI].each do |input| + it "should reject #{input.inspect}" do + expect { Puppet::String::Option.new(string, :foo, :type => input) }. + should raise_error ArgumentError, /not a valid type/ + end + end + end + + + # name short value type + # ca-location CA_LOCATION string + # debug d ---- boolean + # verbose v ---- boolean + # terminus TERMINUS string + # format FORMAT symbol + # mode r RUNMODE limited set of symbols + # server URL URL +end diff --git a/spec/unit/string_spec.rb b/spec/unit/string_spec.rb index 64d4f12f8..577505186 100755 --- a/spec/unit/string_spec.rb +++ b/spec/unit/string_spec.rb @@ -1,84 +1,173 @@ #!/usr/bin/env ruby require File.expand_path(File.dirname(__FILE__) + '/../spec_helper.rb') describe Puppet::String do before :all do @strings = Puppet::String::StringCollection.instance_variable_get("@strings").dup end before :each do Puppet::String::StringCollection.instance_variable_get("@strings").clear end after :all do Puppet::String::StringCollection.instance_variable_set("@strings", @strings) end describe "#define" do it "should register the string" do string = Puppet::String.define(:string_test_register, '0.0.1') string.should == Puppet::String[:string_test_register, '0.0.1'] end it "should load actions" do Puppet::String.any_instance.expects(:load_actions) Puppet::String.define(:string_test_load_actions, '0.0.1') end it "should require a version number" do proc { Puppet::String.define(:no_version) }.should raise_error(ArgumentError) end end describe "#initialize" do it "should require a version number" do proc { Puppet::String.new(:no_version) }.should raise_error(ArgumentError) end it "should require a valid version number" do proc { Puppet::String.new(:bad_version, 'Rasins') }.should raise_error(ArgumentError) end it "should instance-eval any provided block" do face = Puppet::String.new(:string_test_block,'0.0.1') do action(:something) do invoke { "foo" } end end face.something.should == "foo" end end it "should have a name" do Puppet::String.new(:me,'0.0.1').name.should == :me end it "should stringify with its own name" do Puppet::String.new(:me,'0.0.1').to_s.should =~ /\bme\b/ end it "should allow overriding of the default format" do face = Puppet::String.new(:me,'0.0.1') face.set_default_format :foo face.default_format.should == :foo end it "should default to :pson for its format" do Puppet::String.new(:me, '0.0.1').default_format.should == :pson end # Why? it "should create a class-level autoloader" do Puppet::String.autoloader.should be_instance_of(Puppet::Util::Autoload) end it "should try to require strings that are not known" do Puppet::String::StringCollection.expects(:require).with "puppet/string/foo" Puppet::String::StringCollection.expects(:require).with "foo@0.0.1/puppet/string/foo" Puppet::String[:foo, '0.0.1'] end it "should be able to load all actions in all search paths" + + describe "with string-level options" do + it "should support options without arguments" do + string = Puppet::String.new(:with_options, '0.0.1') do + option :foo + end + string.should be_an_instance_of Puppet::String + string.should be_option :foo + end + + it "should support options with an empty block" do + string = Puppet::String.new(:with_options, '0.0.1') do + option :foo do + # this section deliberately left blank + end + end + string.should be_an_instance_of Puppet::String + string.should be_option :foo + end + + it "should return all the string-level options" do + string = Puppet::String.new(:with_options, '0.0.1') do + option :foo + option :bar + end + string.options.should =~ [:foo, :bar] + end + + it "should not return any action-level options" do + string = Puppet::String.new(:with_options, '0.0.1') do + option :foo + option :bar + action :baz do + option :quux + end + end + string.options.should =~ [:foo, :bar] + end + + it "should fail when a duplicate option is added" do + expect { + Puppet::String.new(:action_level_options, '0.0.1') do + option :foo + option :foo + end + }.should raise_error ArgumentError, /option foo already defined for/i + end + + it "should fail when a string option duplicates an action option" do + expect { + Puppet::String.new(:action_level_options, '0.0.1') do + action :bar do option :foo end + option :foo + end + }.should raise_error ArgumentError, /foo already defined on action bar/i + end + + it "should work when two actions have the same option" do + string = Puppet::String.new(:with_options, '0.0.1') do + action :foo do option :quux end + action :bar do option :quux end + end + + string.get_action(:foo).options.should =~ [:quux] + string.get_action(:bar).options.should =~ [:quux] + end + end + + describe "with inherited options" do + let :string do + parent = Class.new(Puppet::String) + parent.option(:inherited, :type => :string) + string = parent.new(:example, '0.2.1') + string.option(:local) + string + end + + describe "#options" do + it "should list inherited options" do + string.options.should =~ [:inherited, :local] + end + end + + describe "#get_option" do + it "should return an inherited option object" do + string.get_option(:inherited).should be_an_instance_of Puppet::String::Option + end + end + end end