diff --git a/lib/puppet/functions.rb b/lib/puppet/functions.rb index dfce58db4..c05bd2aac 100644 --- a/lib/puppet/functions.rb +++ b/lib/puppet/functions.rb @@ -1,636 +1,640 @@ module Puppet::Functions # Creates a new Puppet Function Class with the given func_name with functionality defined by the given block. # The func name should be an unqualified lower case name. The block is evaluated as when a derived Ruby class # is created and it is intended (in the simplest case) that the user defines the actual function in a method named # the same as the function (as shown in the first example below). # # @example A simple function # Puppet::Functions.create_function('min') do # def min(a, b) # a <= b ? a : b # end # end # # Documentation for the function should be placed as comments to the method(s) that define the functionality # The simplest form of defining a function introspects the method signature (in the example `min(a,b)`) and # infers that this means that there are 2 required arguments of Object type. If something else is wanted # the method `dispatch` should be called in the block defining the function to define the details of dispatching # a call of the function. # # In the next example, the function is enhanced to check that arguments are of numeric type. # # @example dispatch and type checking # Puppet::Functions.create_function('min') do # dispatch :min do # param Numeric, 'a' # param Numeric, 'b' # end # # def min(a, b) # a <= b ? a : b # end # end # # It is possible to specify multiple type signatures as defined by the param specification in the dispatch method, and # dispatch to the same, or alternative methods. # When a call is processed the given type signatures are tested in the order they were defined - the first signature # with matching type wins. # # Polymorphic Dispatch # --- # The dispatcher also supports polymorphic dispatch where the method to call is selected based on the type of the # first argument. It is possible to mix regular and polymorphic dispatching, the first with a matching signature wins # in all cases. (Typically one or the other dispatch type is selected for a given function). # # Polymorphic dispatch is based on a method prefix, followed by "_ClassName" where "ClassName" is the simple name # of the class of the first argument. # # @example using polymorphic dispatch # Puppet::Functions.create_function('label') do # dispatch_polymorph do # param Object, 'label' # end # # def label_Object(o) # "A Ruby object of class #{o.class}" # end # # def label_String(o) # "A String with value '#{o}'" # end # end # # In this example, if the argument is a String, a special label is produced and for all others a generic label is # produced. It is now easy to add `label_` methods for other classes as needed without changing the dispatch. # # The type specification of the signature that follows the name of the method are given to the # `Puppet::Pops::Types::TypeFactory` to create a PTupleType. # # Arguments may be Puppet Type References in String form, Ruby classes (for basic types), or Puppet Type instances # as created by the Puppet::Pops::Types::TypeFactory. To make type creation convenient, the logic that builds a dispatcher # redirects any calls to the type factory. # # Injection Support # === # The Function API supports injection of data and services. It is possible to make injection that takes effect # when the function is loaded (for services and runtime configuration that does not change depending on how/from where # in what context the function is called. It is also possible to inject and weave argument values into a call. # # Injection of attributes # --- # Injection of attributes is performed by one of the methods `attr_injected`, and `attr_injected_producer`. # # @example using injected attributes # Puppet::Functions.create_function('test') do # attr_injected String, :larger, 'message_larger' # attr_injected String, :smaller, 'message_smaller' # def test(a, b) # a > b ? larger() : smaller() # end # end # # Injection and Weaving of parameters # --- # It is possible to inject and weave parameters into a call. These extra parameters are not passed from the # Puppet logic. # # @example using injected parameters # Puppet::Functions.create_function('test') do # dispatch :test do # param Scalar, 'a' # param Scalar, 'b' # injected_param String, 'larger', 'message_larger' # injected_param String, 'smaller', 'message_smaller' # end # def test(a, b, larger, smaller) # a > b ? larger : smaller # end # end # The function in the example above is called like this: # # test(10, 20) # + # @param func_name [String, Symbol] a simple or qualified function name + # @param &block [Proc] the block that defines the methods and dispatch of the Function to create + # @return [Class] the newly created Function class + # def self.create_function(func_name, &block) - + func_name = func_name.to_s # Creates an anonymous class to represent the function # The idea being that it is garbage collected when there are no more # references to it. # the_class = Class.new(Function, &block) # TODO: The func_name should be a symbol - else error # Why symbol? They are sticky in memory and the qualified name used in PP is a Fully qualified string # It should probably be either a QualifiedName (counting on it to already be validated? or check again? or # a string # Assume String for now, and that names are properly formed... # Later, must handle name spacing of function, and only use last part as the actual name - better with two # parameters, namespace, and func_name perhaps - or maybe namespace is derived from where it is found, which is # even better # # Make the anonymous class appear to have the class-name # Even if this class is not bound to such a symbol in a global ruby scope and # must be resolved via the loader. # This also overrides any attempt to define a name method in the given block # (Since it redefines it) # - # TODO, the final name of the function class should also reflect the name space # TODO, enforce name in lower case (to further make it stand out since Ruby class names are upper case) # the_class.instance_eval do @func_name = func_name def name @func_name end end # Automatically create an object dispatcher based on introspection if the loaded user code did not # define any dispatchers. Fail if function name does not match a given method name in user code. # if the_class.dispatcher.empty? - type, names = default_dispatcher(the_class, func_name) - the_class.dispatcher.add_dispatch(type, func_name, names, nil, nil) + simple_name = func_name.split(/::/)[-1] + type, names = default_dispatcher(the_class, simple_name) + the_class.dispatcher.add_dispatch(type, simple_name, names, nil, nil) end # The function class is returned as the result of the create function method the_class end # Creates a default dispatcher configured from a method with the same name as the function def self.default_dispatcher(the_class, func_name) unless the_class.method_defined?(func_name) raise ArgumentError, "Function Creation Error, cannot create a default dispatcher for function '#{func_name}', no method with this name found" end object_signature(*min_max_param(the_class.instance_method(func_name))) end def self.min_max_param(method) # Ruby 1.8.7 does not have support for details about parameters if method.respond_to?(:parameters) result = {:req => 0, :opt => 0, :rest => 0 } # TODO: Optimize into one map iteration that produces names map, and sets count as side effect method.parameters.each { |p| result[p[0]] += 1 } from = result[:req] to = result[:rest] > 0 ? :default : from + result[:opt] names = method.parameters.map {|p| p[1] } else # Cannot correctly compute the signature in Ruby 1.8.7 because arity for optional values is # screwed up (there is no way to get the upper limit), an optional looks the same as a varargs # In this case - the failure will simply come later when the call fails # arity = method.arity from = arity >= 0 ? arity : -arity -1 to = arity >= 0 ? arity : :default # i.e. infinite (which is wrong when there are optional - flaw in 1.8.7) names = [] # no names available end [from, to, names] end def self.object_signature(from, to, names) # Construct the type for the signature # Array[Object], Integer[from, to]] factory = Puppet::Pops::Types::TypeFactory optional_object = factory.object [factory.constrain_size(factory.array_of(optional_object), from, to), names] end class Function # The scope where the function is defined attr_reader :closure_scope # The loader that loaded this function # Should be used if function wants to load other things. # attr_reader :loader def initialize(closure_scope, loader) @closure_scope = closure_scope @loader = loader end def call(scope, *args) self.class.dispatcher.dispatch(self, scope, args) end def self.define_dispatch(&block) builder = DispatcherBuilder.new(dispatcher) builder.instance_eval &block end def self.dispatch(meth_name, &block) builder = DispatcherBuilder.new(dispatcher) builder.instance_eval do dispatch(meth_name, &block) end end def self.dispatch_polymorph(meth_name, &block) builder = DispatcherBuilder.new(dispatcher) builder.instance_eval do dispatch_polymorph(meth_name, &block) end end # Defines class level injected attribute with reader method # def self.attr_injected(type, attribute_name, injection_name = nil) define_method(attribute_name) do ivar = :"@#{attribute_name.to_s}" unless instance_variable_defined?(ivar) injector = Puppet.lookup(:injector) instance_variable_set(ivar, injector.lookup(closure_scope, type, injection_name)) end instance_variable_get(ivar) end end # Defines class level injected producer attribute with reader method # def self.attr_injected_producer(type, attribute_name, injection_name = nil) define_method(attribute_name) do ivar = :"@#{attribute_name.to_s}" unless instance_variable_defined?(ivar) injector = Puppet.lookup(:injector) instance_variable_set(ivar, injector.lookup_producer(closure_scope, type, injection_name)) end instance_variable_get(ivar) end end def self.dispatcher @dispatcher ||= Dispatcher.new end # Delegates method calls not supported by Function.class to the TypeFactory # def self.method_missing(meth, *args, &block) if Puppet::Pops::Types::TypeFactory.respond_to?(meth) Puppet::Pops::Types::TypeFactory.send(meth, *args, &block) else super end end def self.respond_to?(meth, include_all=false) Puppet::Pops::Types::TypeFactory.respond_to?(meth, include_all) || super end end class DispatcherBuilder def initialize(dispatcher) @dispatcher = dispatcher end # Delegates method calls not supported by Function.class to the TypeFactory # def method_missing(meth, *args, &block) if Puppet::Pops::Types::TypeFactory.respond_to?(meth) Puppet::Pops::Types::TypeFactory.send(meth, *args, &block) else super end end def respond_to?(meth, include_all=false) Puppet::Pops::Types::TypeFactory.respond_to?(meth, include_all) || super end def dispatch(meth_name, &block) # an array of either an index into names/types, or an array with injection information [type, name, injection_name] # used when the call is being made to weave injections into the given arguments. # @types = [] @names = [] @weaving = [] @injections = [] @min = nil @max = nil self.instance_eval &block @dispatcher.add_dispatch(self.class.create_tuple(@types, @min, @max), meth_name, @names, @injections, @weaving) end def dispatch_polymorph(meth_name, &block) @types = [] @names = [] @weaving = [] @injections = [] @min = nil @max = nil self.instance_eval &block @dispatcher.add_polymorph_dispatch(self.class.create_tuple(@types, @min, @max), meth_name, @names, @injections, @weaving) end def param(type, name) @types << type @names << name # mark what should be picked for this position when dispatching @weaving << @names.size()-1 end # TODO: is param name really needed? Perhaps for error messages? (it is unused now) # def injected_param(type, name, injection_name = '') @injections << [type, name, injection_name] # mark what should be picked for this position when dispatching @weaving << [@injections.size() -1] end # TODO: is param name really needed? Perhaps for error messages? (it is unused now) # def injected_producer_param(type, name, injection_name = '') @injections << [type, name, injection_name, :producer] # mark what should be picked for this position when dispatching @weaving << [@injections.size()-1] end # Specifies the min and max occurance of arguments (of the specified types) if something other than # the exact count from the number of specified types). The max value may be specified as -1 if an infinite # number of arguments are supported. When max is > than the number of specified types, the last specified type # repeats. # def arg_count(min_occurs, max_occurs) @min = min_occurs @max = max_occurs unless min_occurs.is_a?(Integer) && min_occurs >= 0 raise ArgumentError, "min arg_count of function parameter must be an Integer >=0, got #{min_occurs.class} '#{min_occurs}'" end unless max_occurs == :default || (max_occurs.is_a?(Integer) && max_occurs >= 0) raise ArgumentError, "max arg_count of function parameter must be an Integer >= 0, or :default, got #{max_occurs.class} '#{max_occurs}'" end - unless max_occurs == :default || (max_occurs.is_a?(integer) && max_occurs >= min_occurs) + unless max_occurs == :default || (max_occurs.is_a?(Integer) && max_occurs >= min_occurs) raise ArgumentError, "max arg_count must be :default (infinite) or >= min arg_count, got min: '#{min_occurs}, max: '#{max_occurs}'" end end # Handles creation of a tuple type from strings, puppet types, or ruby types and allows # the min/max occurs of the given types to be given as one or two integer values at the end. # def self.create_tuple(types, from, to) mapped_types = types.map do |t| case t when String type_parser ||= Puppet::Pops::Types::TypeParser.new type_parser.parse(t) when Puppet::Pops::Types::PAbstractType t when Class Puppet::Pops::Types::TypeFactory.type_of(t) else raise ArgumentError, "Type signature argument must be a Puppet Type, Class, or a String reference to a type. Got #{t.class}" end end tuple_t = Puppet::Pops::Types::TypeFactory.tuple(*mapped_types) if !(from.nil? && to.nil?) Puppet::Pops::Types::TypeFactory.constrain_size(tuple_t, from,to) else tuple_t end end end # This is a smart dispatcher # For backwards compatible (untyped) API, the dispatcher only enforces simple count, and can be simpler internally # class Dispatcher attr_reader :dispatchers def initialize() @dispatchers = [ ] end # Answers if dispatching has been defined # @return [Boolean] true if dispatching has been defined # def empty? @dispatchers.empty? end # Dispatches the call to the first found signature (entry with matching type). # # @param instance [Puppet::Functions::Function] - the function to call # @param calling_scope [T.B.D::Scope] - the scope of the caller # @param args [Array] - the given arguments in the form of an Array # @return [Object] - what the called function produced # def dispatch(instance, calling_scope, args) tc = Puppet::Pops::Types::TypeCalculator actual = tc.infer_set(args) found = @dispatchers.find { |d| tc.assignable?(d.type, actual) } if found found.invoke(instance, calling_scope, args) else raise ArgumentError, "function '#{instance.class.name}' called with mis-matched arguments\n#{diff_string(instance.class.name, actual)}" end end # Adds a regular dispatch for one method name # # @param type [Puppet::Pops::Types::PArrayType, Puppet::Pops::Types::PTupleType] - type describing signature # @param method_name [String] - the name of the method that will be called when type matches given arguments # @param names [Array] - array with names matching the number of parameters specified by type (or empty array) # def add_dispatch(type, method_name, param_names, injections, weaving) @dispatchers << Dispatch.new(type, NonPolymorphicVisitor.new(method_name), param_names, injections, weaving) end # Adds a polymorph dispatch for one method name # # @param type [Puppet::Pops::Types::PArrayType, Puppet::Pops::Types::PTupleType] - type describing signature # @param method_name [String] - the name of the (polymorph) method that will be called when type matches given arguments # @param names [Array] - array with names matching the number of parameters specified by type (or empty array) # def add_polymorph_dispatch(type, method_name, param_names, injections, weaving) # Type is a CollectionType, its size-type indicates min/max args # This includes the polymorph object which needs to be deducted from the # number of additional args # NOTE: the type is valuable if there are type constraints also on the first arg # (better error message) range = type.size_range # get .from, .to, unbound if nil (from must be bound, to can be nil) raise ArgumentError, "polymorph dispath on collection type without range" unless range raise ArgumentError, "polymorph dispatch on signature without object" if range[0] < 1 from = range[0] - 1 # The object itself is not included to = range[1] -1 # object not included here either (it may be infinity, but does not matter) if !injections.empty? from += injections.size to += injections.size end to = (to == Puppet::Pops::Types::INFINITY) ? -1 : to @dispatchers << Dispatch.new(type, Puppet::Pops::Visitor.new(self, method_name, from, to), param_names, injections, weaving) # @dispatchers << [ type, Puppet::Pops::Visitor.new(self, method_name, from, to), param_names, injections, weaving ] end private class Dispatch attr_reader :type attr_reader :visitor attr_reader :param_names attr_reader :injections attr_reader :weaving def initialize(type, visitor, param_names, injections, weaving) @type = type @visitor = visitor @param_names = param_names || [] @injections = injections || [] @weaving = weaving end def invoke(instance, calling_scope, args) @visitor.visit_this(instance, *weave(calling_scope, args)) end def weave(scope, args) # no nead to weave if there are no injections if injections.empty? args else injector = Puppet.lookup(:injector) weaving.map do |knit| if knit.is_a?(Array) injection_data = @injections[knit[0]] # inject if injection_data[3] == :producer injector.lookup_producer(scope, injection_data[0], injection_data[2]) else injector.lookup(scope, injection_data[0], injection_data[2]) end else # pick that argument args[knit] end end end end end # Produces a string with the difference between the given arguments and support signature(s). # def diff_string(name, args_type) result = [ ] if @dispatchers.size < 2 params_type = @dispatchers[ 0 ].type params_names = @dispatchers[ 0 ].param_names result << "expected:\n #{name}(#{signature_string(params_type, params_names)}) - #{arg_count_string(params_type)}" else result << "expected one of:\n" result << (@dispatchers.map {|d| " #{name}(#{signature_string(d.type, d.param_names)}) - #{arg_count_string(d.type)}"}.join("\n")) end result << "\nactual:\n #{name}(#{arg_types_string(args_type)}) - #{arg_count_string(args_type)}" result.join('') end # Produces a string for the signature(s) # def signature_string(args_type, param_names) size_type = args_type.size_type types = case args_type when Puppet::Pops::Types::PTupleType last_range = args_type.repeat_last_range required_count, _ = args_type.size_range args_type.types when Puppet::Pops::Types::PArrayType from, to = args_type.size_range required_count = from # array has just one element, but there may be multiple names that needs to be subtracted from the count # to make it correct for the last named element adjust = max(0, param_names.size() -1) last_range = [max(0, (from - adjust)), (to - adjust)] [ args_type.element_type ] end tc = Puppet::Pops::Types::TypeCalculator # join type with names (types are always present, names are optional) # separate entries with comma # if param_names.empty? result = types.each_with_index.map {|t, index| tc.string(t) + opt_value_indicator(index, required_count, 0) }.join(', ') else limit = param_names.size result = param_names.each_with_index.map do |name, index| [tc.string(types[index] || types[-1]), name].join(' ') + opt_value_indicator(index, required_count, limit) end.join(', ') end # Add {from, to} for the last type # This works for both Array and Tuple since it describes the allowed count of the "last" type element # for both. It does not show anything when the range is {1,1}. # result += range_string(last_range) result end # Why oh why Ruby do you not have a standard Math.max ? def max(a, b) a >= b ? a : b end def opt_value_indicator(index, required_count, limit) count = index + 1 (count > required_count && count < limit) ? '?' : '' end def arg_count_string(args_type) "arg count #{range_string(args_type.size_range, false)}" end def arg_types_string(args_type) types = case args_type when Puppet::Pops::Types::PTupleType last_range = args_type.repeat_last_range args_type.types when Puppet::Pops::Types::PArrayType last_range = args_type.size_range [ args_type.element_type ] end # stringify generalized versions or it will display Integer[10,10] for "10", String['the content'] etc. # note that type must be copied since generalize is a mutating operation tc = Puppet::Pops::Types::TypeCalculator result = types.map { |t| tc.string(tc.generalize!(t.copy)) }.join(', ') # Add {from, to} for the last type # This works for both Array and Tuple since it describes the allowed count of the "last" type element # for both. It does not show anything when the range is {1,1}. # result += range_string(last_range) result end # Formats a range into a string {from, to} with optimizations when: # * from and to are equal => {from} # * from and to are both and 1 and squelch_one == true => '' # * from is 0 and to is 1 => '?' # * to is INFINITY => {from, } # def range_string(size_range, squelch_one = true) from = size_range[ 0 ] to = size_range[ 1 ] if from == to (squelch_one && from == 1) ? '' : "{#{from}}" elsif to == Puppet::Pops::Types::INFINITY "{#{from},}" elsif from == 0 && to == 1 '?' else "{#{from},#{to}}" end end end # Simple non Polymorphic Visitor class NonPolymorphicVisitor attr_reader :name def initialize(name) @name = name end def visit_this(instance, *args) instance.send(name, *args) end end end \ No newline at end of file diff --git a/lib/puppet/loaders.rb b/lib/puppet/loaders.rb new file mode 100644 index 000000000..86bbc03c9 --- /dev/null +++ b/lib/puppet/loaders.rb @@ -0,0 +1,20 @@ +module Puppet + module Pops + require 'puppet/pops/loaders' + + module Loader + require 'puppet/pops/loader/loader' + require 'puppet/pops/loader/base_loader' + require 'puppet/pops/loader/gem_support' + require 'puppet/pops/loader/module_loaders' + require 'puppet/pops/loader/dependency_loader' + require 'puppet/pops/loader/null_loader' + require 'puppet/pops/loader/static_loader' + require 'puppet/pops/loader/puppet_function_instantiator' + require 'puppet/pops/loader/ruby_function_instantiator' + require 'puppet/pops/loader/ruby_legacy_function_instantiator' + require 'puppet/pops/loader/loader_paths' + end + end + +end \ No newline at end of file diff --git a/lib/puppet/pops/loader.rb b/lib/puppet/pops/loader.rb deleted file mode 100644 index 00f8be741..000000000 --- a/lib/puppet/pops/loader.rb +++ /dev/null @@ -1,138 +0,0 @@ -# A loader is responsible for loading "types" (actually -# "instantiable and executable objects in the puppet language" which -# are type, hostclass, definition and function. -# -# The main method for users of a loader is the `load` method, which returns a previously loaded entity -# of a given type/name, and searches and loads the entity if not already loaded. -# -# @api public -# -class Puppet::Pops::Loader - - # Produces the value associated with the given name if already loaded, or available for loading - # by this loader, one of its parents, or other loaders visible to this loader. - # This is the method an external party should use to "get" the named element. - # - # An implementor of this method should first check if the given name is already loaded by self, or a parent - # loader, and if so return that result. If not, it should call #find to perform the loading. - # - # @param type [:Symbol] - the type to load - # @param name [String, Symbol] - the name of the entity to load - # - # @api public - # - def load(type, name) - load_typed(TypedName.new(type, name)) - end - - # The same as load, but acts on a type/name combination. - # - # @param typed_name [TypedName] - the type, name combination to lookup - # - # @api public - # - def load_typed(typed_name) - raise NotImplementedError.new - end - - # Produces the value associated with the given name if defined in this loader, or nil if not defined. - # This lookup does not trigger any loading, or search of the given name. - # An implementor of this method may not search or look up in any other loader, and it may not - # define the name. - # - # @param typed_name [TypedName] - the type, name combination to lookup - # - # @api private - # - def [] (typed_name) - raise NotImplementedError.new - end - - # Searches for the given name in this loaders context (parents have already searched their context(s) without - # producing a result when this method is called). - # - # @param typed_name [TypedName] - the type, name combination to lookup - # - # @api private - # - def find(typed_name) - raise NotImplementedError.new - end - - # Returns the parent of the loader, or nil, if this is the top most loader. This implementation returns nil. - def parent - nil - end - - # Binds a value to a name. The name should not start with '::', but may contain multiple segments. - # - # @param type [:Symbol] - the type of the entity being set - # @param name [String, Symbol] - the name of the entity being set - # @param origin [URI, #uri, String] - the origin of the set entity, a URI, or provider of URI, or URI in string form - # - # @api private - # - def set_entry(type, name, value, origin = nil) - raise NotImplementedError.new - end - - # Produces a NamedEntry if a value is bound to the given name, or nil if nothing is bound. - # - # @param typed_name [TypedName] - the type, name combination to lookup - # - # @api private - # - def get_entry(typed_name) - raise NotImplementedError.new - end - - # An entry for one entity loaded by the loader. - # - class NamedEntry - attr_reader :status - attr_reader :typed_name - attr_reader :value - attr_reader :origin - - def initialize(status, typed_name, value, origin) - @status = status - @name = typed_name - @value = value - @origin = origin - freeze() - end - end - - # A name/type combination that can be used as a compound hash key - # - class TypedName - attr_reader :type - attr_reader :name - def initialize(type, name) - @type = type - @name = Puppet::Pops::Utils.relativize_name(name) - - # Not allowed to have numeric names - 0, 010, 0x10, 1.2 etc - if Puppet::Pops::Utils.is_numeric?(@name) - raise ArgumentError, "Illegal attempt to use a numeric name '#{name}' at #{origin_label(origin)}." - end - - freeze() - end - - def hash - [self.class, type, name] - end - - def ==(o) - o.class == self.class && type == o.type && name == o.name - end - - alias eql? == - - def to_s - "#{type}/#{name}" - end - end -end - diff --git a/lib/puppet/pops/loader/base_loader.rb b/lib/puppet/pops/loader/base_loader.rb new file mode 100644 index 000000000..c8f732367 --- /dev/null +++ b/lib/puppet/pops/loader/base_loader.rb @@ -0,0 +1,96 @@ +# BaseLoader +# === +# An abstract implementation of Puppet::Pops::Loader::Loader +# +# A derived class should implement `find(typed_name)` and set entries, and possible handle "miss caching". +# +# @api private +# +class Puppet::Pops::Loader::BaseLoader < Puppet::Pops::Loader::Loader + + # The parent loader + attr_reader :parent + + # An internal name used for debugging and error message purposes + attr_reader :loader_name + + def initialize(parent_loader, loader_name) + @parent = parent_loader # the higher priority loader to consult + @named_values = {} # hash name => NamedEntry + @last_name = nil # the last name asked for (optimization) + @last_result = nil # the value of the last name (optimization) + @loader_name = loader_name # the name of the loader (not the name-space it is a loader for) + end + + # @api public + # + def load_typed(typed_name) + # The check for "last queried name" is an optimization when a module searches. First it checks up its parent + # chain, then itself, and then delegates to modules it depends on. + # These modules are typically parented by the same + # loader as the one initiating the search. It is inefficient to again try to search the same loader for + # the same name. + if typed_name == @last_name + @last_result + else + @last_name = typed_name + @last_result = internal_load(typed_name) + end + end + + # This method is final (subclasses should not override it) + # + # @api private + # + def get_entry(typed_name) + @named_values[typed_name] + end + + # @api private + # + def set_entry(typed_name, value, origin = nil) + if entry = @named_values[typed_name] then fail_redefined(entry); end + @named_values[typed_name] = Puppet::Pops::Loader::Loader::NamedEntry.new(typed_name, value, origin) + end + + # Promotes an already created entry (typically from another loader) to this loader + # + # @api private + # + def promote_entry(named_entry) + typed_name = named_entry.typed_name + if entry = @named_values[typed_name] then fail_redefined(entry); end + @named_values[typed_name] = named_entry + end + + private + + def fail_redefine(entry) + origin_info = entry.origin ? " Originally set at #{origin_label(entry.origin)}." : "unknown location" + raise ArgumentError, "Attempt to redefine entity '#{entry.typed_name}' originally set at #{origin_label(origin)}.#{origin_info}" + end + + # TODO: Should not really be here?? - TODO: A Label provider ? semantics for the URI? + # + def origin_label(origin) + if origin && origin.is_a?(URI) + origin.to_s + elsif origin.respond_to?(:uri) + origin.uri.to_s + else + nil + end + end + + # loads in priority order: + # 1. already loaded here + # 2. load from parent + # 3. find it here + # 4. give up + # + def internal_load(typed_name) + # avoid calling get_entry, by looking it up + @named_values[typed_name] || parent.load_typed(typed_name) || find(typed_name) + end + +end diff --git a/lib/puppet/pops/loader/dependency_loader.rb b/lib/puppet/pops/loader/dependency_loader.rb new file mode 100644 index 000000000..0cc1df67f --- /dev/null +++ b/lib/puppet/pops/loader/dependency_loader.rb @@ -0,0 +1,60 @@ +# =DependencyLoader +# This loader provides visibility into a set of other loaders. It is used as a child of a ModuleLoader (or other +# loader) to make its direct dependencies visible for loading from contexts that have access to this dependency loader. +# Access is typically given to logic that resides inside of the module, but not to those that just depend on the module. +# +# It is instantiated with a name, and with a set of dependency_loaders. +# +# @api private +# +class Puppet::Pops::Loader::DependencyLoader < Puppet::Pops::Loader::BaseLoader + + # An index of module_name to module loader used to speed up lookup of qualified names + attr_reader :index + + # Creates a DependencyLoader for one parent loader + # + # @param parent_loader [Puppet::Pops::Loader] - typically a module loader for the root + # @param name [String] - the name of the dependency-loader (used for debugging and tracing only) + # @param depedency_loaders [Array] - array of loaders for modules this module depends on + # + def initialize(parent_loader, name, dependency_loaders) + super parent_loader, name + @dependency_loaders = dependency_loaders + end + + # Finds name in a loader this loader depends on / can see + # + def find(typed_name) + if typed_name.qualified + if loader = index()[typed_name.name_parts[0]] + loader.load_typed(typed_name) + else + # no module entered as dependency with name matching first segment of wanted name + nil + end + else + # a non name-spaced name, have to search since it can be anywhere. + # (Note: superclass caches the result in this loader as it would have to repeat this search for every + # lookup otherwise). + loaded = @dependency_loaders.reduce(nil) do |previous, loader| + break previous if !previous.nil? + loader.load_typed(typed_name) + end + if loaded + promote_entry(loaded) + end + loaded + end + end + + def to_s() + "(DependencyLoader '#{@name}' [" + @dependency_loaders.map {|loader| loader.to_s }.join(' ,') + "])" + end + + private + + def index() + @index ||= @dependency_loaders.reduce({}) { |index, loader| index[loader.module_name] = loader; index } + end +end diff --git a/lib/puppet/pops/loader/gem_support.rb b/lib/puppet/pops/loader/gem_support.rb new file mode 100644 index 000000000..bf58244d0 --- /dev/null +++ b/lib/puppet/pops/loader/gem_support.rb @@ -0,0 +1,49 @@ +# GemSupport offers methods to find a gem's location by name or gem://gemname URI. +# +# TODO: The Puppet 3x, uses Puppet::Util::RubyGems to do this, and obtain paths, and avoids using ::Gems +# when ::Bundler is in effect. A quick check what happens on Ruby 1.8.7 and Ruby 1.9.3 with current +# version of bundler seems to work just fine without jumping through any hoops. Hopefully the Puppet::Utils::RubyGems is +# just dealing with arcane things prior to RubyGems 1.8 that are not needed any more. To verify there is +# the need to set up a scenario where additional bundles than what Bundler allows for a given configuration are available +# and then trying to access those. +# +module Puppet::Pops::Loader::GemSupport + + # Produces the root directory of a gem given as an URI (gem://gemname/optional/path), or just the + # gemname as a string. + # + def gem_dir(uri_or_string) + case uri_or_string + when URI + gem_dir_from_uri(uri_or_string) + when String + gem_dir_from_name(uri_or_string) + end + end + + # Produces the root directory of a gem given as an uri, where hostname is the gemname, and an optional + # path is appended to the root of the gem (i.e. if the reference is given to a sub-location within a gem. + # TODO: FIND by name raises exception Gem::LoadError with list of all gems on the path + # + def gem_dir_from_uri(uri) + unless spec = Gem::Specification.find_by_name(uri.hostname) + raise ArgumentError, "Gem not found #{uri}" + end + # if path given append that, else append given subdir + if uri.path.empty? + spec.gem_dir + else + File.join(spec.full_gem_path, uri.path) + end + end + + # Produces the root directory of a gem given as a string with the gem's name. + # TODO: FIND by name raises exception Gem::LoadError with list of all gems on the path + # + def gem_dir_from_name(gem_name) + unless spec = Gem::Specification.find_by_name(gem_name) + raise ArgumentError, "Gem not found '#{gem_name}'" + end + spec.full_gem_path + end +end \ No newline at end of file diff --git a/lib/puppet/pops/loader/loader.rb b/lib/puppet/pops/loader/loader.rb new file mode 100644 index 000000000..68d5cd17c --- /dev/null +++ b/lib/puppet/pops/loader/loader.rb @@ -0,0 +1,172 @@ +# Loader +# === +# A Loader is responsible for loading "entities" ("instantiable and executable objects in the puppet language" which +# are type, hostclass, definition, function, and bindings. +# +# The main method for users of a Loader is the `load` or `load_typed methods`, which returns a previously loaded entity +# of a given type/name, and searches and loads the entity if not already loaded. +# +# private entities +# --- +# TODO: handle loading of entities that are private. Suggest that all calls pass an origin_loader (the loader +# where request originated (or symbol :public). A module loader has one (or possibly a list) of what is +# considered to represent private loader - i.e. the dependency loader for a module. If an entity is private +# it should be stored with this status, and an error should be raised if the origin_loader is not on the list +# of accepted "private" loaders. +# The private loaders can not be given at creation time (they are parented by the loader in question). Another +# alternative is to check if the origin_loader is a child loader, but this requires bidirectional links +# between loaders or a search if loader with private entity is a parent of the origin_loader). +# +# @api public +# +class Puppet::Pops::Loader::Loader + + # Produces the value associated with the given name if already loaded, or available for loading + # by this loader, one of its parents, or other loaders visible to this loader. + # This is the method an external party should use to "get" the named element. + # + # An implementor of this method should first check if the given name is already loaded by self, or a parent + # loader, and if so return that result. If not, it should call `find` to perform the loading. + # + # @param type [:Symbol] the type to load + # @param name [String, Symbol] the name of the entity to load + # @return [Object, nil] the value or nil if not found + # + # @api public + # + def load(type, name) + if result = load_typed(TypedName.new(type, name)) + result.value + end + end + + # Loads the given typed name, and returns a NamedEntry if found, else returns nil. + # This the same a `load`, but returns a NamedEntry with origin/value information. + # + # @param typed_name [TypedName] - the type, name combination to lookup + # @return [NamedEntry, nil] the entry containing the loaded value, or nil if not found + # + # @api public + # + def load_typed(typed_name) + raise NotImplementedError.new + end + + # Produces the value associated with the given name if defined **in this loader**, or nil if not defined. + # This lookup does not trigger any loading, or search of the given name. + # An implementor of this method may not search or look up in any other loader, and it may not + # define the name. + # + # @param typed_name [TypedName] - the type, name combination to lookup + # + # @api private + # + def [] (typed_name) + if found = get_entry(typed_name) + found.value + else + nil + end + end + + # Searches for the given name in this loader's context (parents should already have searched their context(s) without + # producing a result when this method is called). + # An implementation of find typically caches the result. + # + # @param typed_name [TypedName] the type, name combination to lookup + # @return [NamedEntry, nil] the entry for the loaded entry, or nil if not found + # + # @api private + # + def find(typed_name) + raise NotImplementedError.new + end + + # Returns the parent of the loader, or nil, if this is the top most loader. This implementation returns nil. + def parent + nil + end + + # Binds a value to a name. The name should not start with '::', but may contain multiple segments. + # + # @param type [:Symbol] the type of the entity being set + # @param name [String, Symbol] the name of the entity being set + # @param origin [URI, #uri, String] the origin of the set entity, a URI, or provider of URI, or URI in string form + # @return [NamedEntry, nil] the created entry + # + # @api private + # + def set_entry(type, name, value, origin = nil) + raise NotImplementedError.new + end + + # Produces a NamedEntry if a value is bound to the given name, or nil if nothing is bound. + # + # @param typed_name [TypedName] the type, name combination to lookup + # @return [NamedEntry, nil] the value bound in an entry + # + # @api private + # + def get_entry(typed_name) + raise NotImplementedError.new + end + + # An entry for one entity loaded by the loader. + # + class NamedEntry + attr_reader :typed_name + attr_reader :value + attr_reader :origin + + def initialize(typed_name, value, origin) + @name = typed_name + @value = value + @origin = origin + freeze() + end + end + + # A name/type combination that can be used as a compound hash key + # + class TypedName + attr_reader :type + attr_reader :name + attr_reader :name_parts + + # True if name is qualified (more than a single segment) + attr_reader :qualified + + def initialize(type, name) + @type = type + # relativize the name (get rid of leading ::), and make the split string available + @name_parts = name.split(/::/) + @name_parts.shift if name_parts[0].empty? + @name = name_parts.join('::') + @qualified = name_parts.size > 1 + # precompute hash - the name is frozen, so this is safe to do + @hash = [self.class, type, @name].hash + + # Not allowed to have numeric names - 0, 010, 0x10, 1.2 etc + if Puppet::Pops::Utils.is_numeric?(@name) + raise ArgumentError, "Illegal attempt to use a numeric name '#{name}' at #{origin_label(origin)}." + end + + freeze() + end + + def hash + @hash + end + + def ==(o) + o.class == self.class && type == o.type && name == o.name + end + + alias eql? == + + def to_s + "#{type}/#{name}" + end + end +end + diff --git a/lib/puppet/pops/loader/loader_paths.rb b/lib/puppet/pops/loader/loader_paths.rb new file mode 100644 index 000000000..41f78ebc3 --- /dev/null +++ b/lib/puppet/pops/loader/loader_paths.rb @@ -0,0 +1,156 @@ + +# LoaderPaths +# === +# The central loader knowledge about paths, what they represent and how to instantiate from them. +# Contains helpers (*smart paths*) to deal with lazy resolution of paths. +# +# TODO: Currently only supports loading of functions (3 kinds) +# +module Puppet::Pops::Loader::LoaderPaths + # Returns an array of SmartPath, each instantiated with a reference to the given loader (for root path resolution + # and existence checks). The smart paths in the array appear in precedence order. The returned array may be + # mutated. + # + def self.relative_paths_for_type(type, loader) #, start_index_in_name) + result = + case type # typed_name.type + when :function + [FunctionPath4x.new(loader), FunctionPath3x.new(loader), FunctionPathPP.new(loader)] + + # when :xxx # TODO: Add all other types + + else + # unknown types, simply produce an empty result; no paths to check, nothing to find... move along... + [] + end + result + end + +# # DO NOT REMOVE YET. needed later? when there is the need to decamel a classname +# def de_camel(fq_name) +# fq_name.to_s.gsub(/::/, '/'). +# gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). +# gsub(/([a-z\d])([A-Z])/,'\1_\2'). +# tr("-", "_"). +# downcase +# end + + class SmartPath + # Generic path, in the sense of "if there are any entities of this kind to load, where are they?" + attr_reader :generic_path + + # Creates SmartPath for the given loader (loader knows how to check for existence etc.) + def initialize(loader) + @loader = loader + end + + def generic_path() + return @generic_path unless @generic_path.nil? + + root_path = @loader.path + @generic_path = (root_path.nil? ? relative_path : File.join(root_path, relative_path)) + end + + # Effective path is the generic path + the name part(s) + extension. + # + def effective_path(typed_name, start_index_in_name) + "#{File.join(generic_path, typed_name.name_parts[ start_index_in_name..-1 ])}#{extension}" + end + + def relative_path() + raise NotImplementedError.new + end + + def instantiator() + raise NotImplementedError.new + end + end + + class RubySmartPath < SmartPath + def extension + ".rb" + end + + # Duplication of extension information, but avoids one call + def effective_path(typed_name, start_index_in_name) + "#{File.join(generic_path, typed_name.name_parts[ start_index_in_name..-1 ])}.rb" + end + end + + class PuppetSmartPath < SmartPath + def extension + ".pp" + end + + # Duplication of extension information, but avoids one call + def effective_path(typed_name, start_index_in_name) + "#{File.join(generic_path, typed_name.name_parts[ start_index_in_name..-1 ])}.pp" + end + end + + class FunctionPath4x < RubySmartPath + FUNCTION_PATH_4X = File.join('lib', 'puppet', 'functions') + + def relative_path + FUNCTION_PATH_4X + end + + def instantiator() + Puppet::Pops::Loader::RubyFunctionInstantiator + end + end + + class FunctionPath3x < RubySmartPath + FUNCTION_PATH_3X = File.join('lib', 'puppet', 'parser', 'functions') + + def relative_path + FUNCTION_PATH_3X + end + + def instantiator() + Puppet::Pops::Loader::RubyLegacyFunctionInstantiator + end + end + + class FunctionPathPP < PuppetSmartPath + FUNCTION_PATH_PP = 'functions' + + def relative_path + FUNCTION_PATH_PP + end + + def instantiator() + Puppet::Pops::Loader::PuppetFunctionInstantiator + end + end + + # SmartPaths + # === + # Holds effective SmartPath instances per type + # + class SmartPaths + def initialize(path_based_loader) + @loader = path_based_loader + @smart_paths = {} + end + + # Ensures that the paths for the type have been probed and pruned to what is existing relative to + # the given root. + # + # @param type [Symbol] the entity type to load + # @return [Array] array of effective paths for type (may be empty) + # + def effective_paths(type) + smart_paths = @smart_paths + loader = @loader + unless effective_paths = smart_paths[type] + # type not yet processed, does the various directories for the type exist ? + # Get the relative dirs for the type + paths_for_type = Puppet::Pops::Loader::LoaderPaths.relative_paths_for_type(type, loader) + # Check which directories exist in the loader's content/index + effective_paths = smart_paths[type] = paths_for_type.select { |sp| loader.meaningful_to_search?(sp) } + end + effective_paths + end + end +end diff --git a/lib/puppet/pops/loader/module_loader_configurator.rb b/lib/puppet/pops/loader/module_loader_configurator.rb new file mode 100644 index 000000000..c7dd581ea --- /dev/null +++ b/lib/puppet/pops/loader/module_loader_configurator.rb @@ -0,0 +1,245 @@ +require 'puppet/pops/impl/loader/uri_helper' +require 'delegator' + +module Puppet; module Pops; module Impl; end; end; end +module Puppet::Pops::Impl::Loader + + # A ModuleLoaderConfigurator is responsible for configuring module loaders given a module path + # NOTE: Exploratory code (not yet fully featured/functional) showing how a configurator loads and configures + # ModuleLoaders for a given set of modules on a given module path. + # + # ==Usage + # Create an instance and for each wanted entry call one of the methods #add_all_modules, + # or #add_module. A call must be made to #add_root (once only) to define where the root is. + # + # The next step is to produce loaders for the modules. This involves resolving the modules dependencies + # to know what is visible to each module (and later to create a real loader). This can be a quite heavy + # operation and there may be many more modules available than what will actually be used. + # The creation of loaders is therefore performed lazily. + # + # A call to #create_loaders sets up lazy loaders for all modules and creates the real loader for + # the root. + # + class ModuleLoaderConfigurator + include Puppet::Pops::Impl::Loader::UriHelper + + def initialize + @root_module = nil + # maps module name to list of ModuleData (different versions of module) + @module_name_to_data = {} + @modules = [] # list in search path order + end + + # =ModuleData + # Reference to a module's data + # TODO: should have reference to real model element containing all module data; this is faking it + class ModuleData + attr_accessor :name, :version, :state, :loader, :path, :module_element, :resolutions + def initialize name, version, path + @name = name + @version = version + @path = path + @state = :unresolved + @module_element = nil # should be a model element describing the module + @resolutions = [] + @loader = nil + end + def requirements + nil # FAKE: this says "wants to see everything" + end + def is_resolved? + @state == :resolved + end + end + + # Produces module loaders for all modules and returns the loader for the root. + # All other module loaders are parented by this loader. The root loader is parented by + # the given parent_loader. + # + def create_loaders(parent_loader) + # TODO: If no root was configured - what to do? Fail? Or just run with a bunch of modules? + # Configure a null module? + # Create a lazy loader first, all the other modules needs to refer to something, and + # the real module loader needs to know about the other loaders when created. + @root_module.loader = SimpleDelegator.new(LazyLoader.new(parent_loader, @root_module, self)) + @modules.each { |md| md.loader = SimpleDelegator.new(LazyLoader.new(@root_module.loader, md, self)) } + + # Since we can be almost certain that the root loader will be used, resolve it and + # replace with a real loader. Also, since the root module does not have a name, it can not + # use the optimizing scheme in LazyLoader where the loader stays unresolved until a name in the + # module's namespace is actually requested. + @root_module.loader = @root_module.loader.create_real_loader + end + + # Lazy loader is used via a Delegator. When invoked to do some real work, it checks + # if the requested name is for this module or not - such a request can never come from within + # logic in the module itself (since that would have required it to be resolved in the first place). + # + class LazyLoader + def initialize parent, module_data, configurator + @module_data = module_data + @parent = parent + @configurator = configurator + end + + def [](name) + # Since nothing has ever been loaded, this can be answered truthfully. + nil + end + + def load(name, executor) + matching_name?(name) ? create_real_loader.load(name, executor) : nil + end + + def find(name, executor) + # Calls should always go to #load first, and since that always triggers the + # replacement and delegation to the real thing, this should never happen. + raise "Should never have been called" + end + + def parent + # This can be answered truthfully without resolving the loader. + @parent + end + + def matching_name?(name) + name = name[2..-1] if name.start_with?("::") + segments = name.split("::") + @module_data.name == segments[0] || (segments.size == 1 && non_namespace_name_exists?(segments[0])) + end + + def non_namespace_name_exists? name + # a file with given name (any extension) under /types or /functions + Dir[*([@module_data.path].product(%w{/types /functions}, [name+'.*']).collect{|a| File.join(a)})].size != 0 + end + + # Creates the real ModuleLoader, updates the Delegator handed out to other loaders earlier + # + def create_real_loader + md = @module_data + @configurator.resolve_module md + loaders_for_resolved = md.resolutions.collect { |m| m.loader } + real = ModuleLoader.new(parent, md.name, md.path, loaders_for_resolved) + md.loader.__setobj__(real) + real + end + end + + # Path should refer to a directory where there are sub-directories for 'manifests', and + # other loadable things under puppet/type, puppet/function/... + # This does *not* add any modules found under this root. + # + def add_root path + data= ModuleData.new('', :unversioned, path) + @root_module = data + end + + # Path should refer to a directory of 'modules', where each directory is named after the module it contains. + # + def add_all_modules path + path = path_for_uri(path, '') + raise "Path to modules is not a directory" unless File.directory? path + # Process content of directory in sorted order to make it deterministic + # (Needed because types and functions are not in a namespace in Puppet; thus order matters) + # + Dir[file_name + '/*'].sort.each do |f| + next unless File.directory? f + add_module File.basename(f), f + end + end + + # Path should refer to the root directory of a module. Allows a gem:, file: or nil scheme (file). + # The path may be a URI or a String. + # + def add_module name, path + # Allows specification of gem or file + path = path_for_uri(path, '') + + # TODO: + # Is there a modulefile.json + # Load it, and get the metadata + # Describe the module, its dependencies etc. + # + + # If there is no Modulefile, or modulefile.json to load - it is still a module + # But its version and dependencies are not known. Create a model for it + # Mark it as "depending on everything in the configuration + + # Beware of circular dependencies; they may require special loading ? + + # Beware of same module appearing more than once (different versions ok, same version, or no + # version is not). + + # Remember the data + # Fake :unversioned etc. + data = ModuleData.new(name, :unversioned, path) + @modules << data # list in order module paths are added + if entries = @module_name_to_data[name] + entries << data + else + @module_name_to_data[name] = [data] + end + end + + def validate + # Scan the remembered modules/versions and check if there are duplicate versions + # and what not... + # TODO: Decide when module validity is determined; tradeoff between startup performance and when + # errors are detected + + # Validate + # - Refers to itself, or different version of itself + # - Metadata and path (name) are not in sync + # + end + + def resolve_all + @module_name_to_data.each { |k, v| v.each { |m| resolve_module m } } + end + + # Resolves a module by looking up all of its requirements and picking the best version + # matching the requirements, alternatively if requirement is "everything", pick the first found + # version of each module by name in path order. + # + def resolve_module md + # list of first added (on module path) by module name + @first_of_each ||= @modules.collect {|m| m.name }.uniq.collect {|k| @module_name_to_data[k][0] } + + # # Alternative Collect latest, modules in order they were found on path + # + # @modules.collect {|m| m.name }.uniq.collect do |k| + # v = theResolver.best_match(">=0", @module_name_to_data[name].collect {|x| x.version}) + # md.resolutions << @module_name_to_data[k].find {|x| x.version == v } + # end + + unless md.is_resolved? + if reqs = md.requirements + reqs.each do |r| + # TODO: This is pseudo code - will fail if used + name = r.name + version_requirements = r.version_requirements + # Ask a (fictitious) resolver to compute the best matching version + v = theResolver.best_match(version_requirements, @module_name_to_data[name].collect {|x| x.version }) + if v + md.resolutions << @module_name_to_data[name].find {|x| x.version == v } + else + raise "TODO: Unresolved" + end + end + else + # nil requirements means "wants to see all" + # Possible solutions: + # - pick the latest version of each named module if there is more than one version + # - pick the first found module on the path (this is probably what Puppet 3x does) + + # Resolutions are all modules (except the current) + md.resolutions += @first_of_each.reject { |m| m == md } + end + md.status = :resolved + end + end + + def configure_loaders + end + end +end \ No newline at end of file diff --git a/lib/puppet/pops/loader/module_loaders.rb b/lib/puppet/pops/loader/module_loaders.rb new file mode 100644 index 000000000..57d618862 --- /dev/null +++ b/lib/puppet/pops/loader/module_loaders.rb @@ -0,0 +1,228 @@ + +# =ModuleLoaders +# A ModuleLoader loads items from a single module. +# The ModuleLoaders (ruby) module contains various such loaders. There is currently one concrete +# implementation, ModuleLoaders::FileBased that loads content from the file system. +# Other implementations can be created - if they are based on name to path mapping where the path +# is relative to a root path, they can derive the base behavior from the ModuleLoaders::AbstractPathBasedModuleLoader class. +# +# Examples of such extensions could be a zip/jar/compressed file base loader. +# +# Notably, a ModuleLoader does not configure itself - it is given the information it needs (the root, its name etc.) +# Logic higher up in the loader hierarchy of things makes decisions based on the "shape of modules", and "available +# modules" to determine which module loader to use for each individual module. (There could be differences in +# internal layout etc.) +# +# A module loader is also not aware of the mapping of name to relative paths - this is performed by the +# included module Puppet::Pops::Loader::PathBasedInstantatorConfig which knows about the map from type/name to +# relative path, and the logic that can instantiate what is expected to be found in the content of that path. +# +# @api private +# +module Puppet::Pops::Loader::ModuleLoaders + class AbstractPathBasedModuleLoader < Puppet::Pops::Loader::BaseLoader + + # The name of the module, or nil, if this is a global "component" + attr_reader :module_name + + # The path to the location of the module/component - semantics determined by subclass + attr_reader :path + + # A map of type to smart-paths that help with minimizing the number of paths to scan + attr_reader :smart_paths + + # Initialize a kind of ModuleLoader for one module + # @param parent_loader [Puppet::Pops::Loader] loader with higher priority + # @param module_name [String] the name of the module (non qualified name), may be nil for a global "component" + # @param path [String] the path to the root of the module (semantics defined by subclass) + # @param loader_name [String] a name that is used for human identification (useful when module_name is nil) + # + def initialize(parent_loader, module_name, path, loader_name) + super parent_loader, loader_name + + # Irrespective of the path referencing a directory or file, the path must exist. + unless Puppet::FileSystem.exist?(path) + raise ArgumentError, "The given path '#{path}' does not exist!" + end + + @module_name = module_name + @path = path + @smart_paths = Puppet::Pops::Loader::LoaderPaths::SmartPaths.new(self) + end + + # Finds typed/named entity in this module + # @param typed_name [Puppet::Pops::Loader::TypedName] the type/name to find + # @return [Puppet::Pops::Loader::Loader::NamedEntry, nil found/created entry, or nil if not found + # + def find(typed_name) + # Assume it is a global name, and that all parts of the name should be used when looking up + name_part_index = 0 + name_parts = typed_name.name_parts + + # Certain types and names can be disqualified up front + if name_parts.size > 1 + # The name is in a name space. + + # Then entity cannot possible be in this module unless the name starts with the module name. + # Note: If "module" represents a "global component", the module_name is nil and cannot match which is + # ok since such a "module" cannot have namespaced content). + # + return nil unless name_parts[0] == module_name + + # Skip the first part of the name when computing the path since the path already contains the name of the + # module + name_part_index = 1 + else + # The name is in the global name space. + + # The only globally name-spaced elements that may be loaded from modules are functions and resource types + case typed_name.type + when :function + when :resource_type + else + # anything else cannot possibly be in this module + # TODO: should not be allowed anyway... may have to revisit this decision + return nil + end + end + + # Get the paths that actually exist in this module (they are lazily processed once and cached). + # The result is an array (that may be empty). + # Find the file to instantiate, and instantiate the entity if file is found + origin = nil + if (smart_path = smart_paths.effective_paths(typed_name.type).find do |sp| + origin = sp.effective_path(typed_name, name_part_index) + existing_path(origin) + end) + value = smart_path.instantiator.create(self, typed_name, origin, get_contents(origin)) + # cache the entry and return it + set_entry(typed_name, value, origin) + else + nil + end + end + + # Abstract method that subclasses override that checks if it is meaningful to search using a generic smart path. + # This optimization is performed to not be tricked into searching an empty directory over and over again. + # The implementation may perform a deep search for file content other than directories and cache this in + # and index. It is guaranteed that a call to meaningful_to_search? takes place before checking any other + # path with relative_path_exists?. + # + # This optimization exists because many modules have been created from a template and they have + # empty directories for functions, types, etc. (It is also the place to create a cached index of the content). + # + # @param relative_path [String] a path relative to the module's root + # @return [Boolean] true if there is content in the directory appointed by the relative path + # + def meaningful_to_search?(smart_path) + raise NotImplementedError.new + end + + # Abstract method that subclasses override to answer if the given relative path exists, and if so returns that path + # + # @param relative_path [String] a path resolved by a smart path against the loader's root (if it has one) + # @return [Boolean] true if the file exists + # + def existing_path(resolved_path) + raise NotImplementedError.new + end + + # Abstract method that subclasses override to produce the content of the effective path. + # It should either succeed and return a String or fail with an exception. + # + # @param relative_path [String] a path as resolved by a smart path + # @return [String] the content of the file + # + def get_contents(effective_path) + raise NotImplementedError.new + end + + # Abstract method that subclasses override to produce a source reference String used to identify the + # system resource (resource in the URI sense). + # + # @param relative_path [String] a path relative to the module's root + # @return [String] a reference to the source file (in file system, zip file, or elsewhere). + # + def get_source_ref(relative_path) + raise NotImplementedError.new + end + end + + # @api private + # + class FileBased < AbstractPathBasedModuleLoader + + attr_reader :smart_paths + attr_reader :path_index + + # Create a kind of ModuleLoader for one module + # The parameters are: + # * parent_loader - typically the loader for the root + # * module_name - the name of the module (non qualified name) + # * path - the path to the root of the module (semantics defined by subclass) + # + def initialize(parent_loader, module_name, path, loader_name) + super + unless Puppet::FileSystem.directory?(path) + raise ArgumentError, "The given module root path '#{path}' is not a directory (required for filesystem based module path entry)" + end + @path_index = Set.new() + end + + def existing_path(effective_path) + # Optimized, checks index instead of visiting file system + @path_index.include?(effective_path) ? effective_path : nil + end + + def meaningful_to_search?(smart_path) + ! add_to_index(smart_path).empty? + end + + def to_s() + "(ModuleLoader::FileBased '#{loader_name()}' '#{module_name()}')" + end + + def add_to_index(smart_path) + found = Dir.glob(File.join(smart_path.generic_path, '**', "*#{smart_path.extension}")) + @path_index.merge(found) + found + end + + def get_contents(effective_path) + Puppet::FileSystem.read(effective_path) + end + end + + # Loads from a gem specified as a URI, gem://gemname/optional/path/in/gem, or just a String gemname. + # The source reference (shown in errors etc.) is the expanded path of the gem as this is believed to be more + # helpful - given the location it should be quite obvious which gem it is, without the location, the user would + # need to go on a hunt for where the file actually is located. + # + # TODO: How does this get instantiated? Does the gemname refelect the name of the module (the namespace) + # or is that specified a different way? Can a gem be the container of multiple modules? + # + # @api private + # + class GemBased < FileBased + include Puppet::Pops::Loader::GemSupport + + attr_reader :gem_ref + + # Create a kind of ModuleLoader for one module + # The parameters are: + # * parent_loader - typically the loader for the root + # * module_name - the name of the module (non qualified name) + # * gem_ref - [URI, String] gem reference to the root of the module (URI, gem://gemname/optional/path/in/gem), or + # just the gem's name as a String. + # + def initialize(parent_loader, module_name, gem_ref, loader_name) + @gem_ref = gem_ref + super parent_loader, module_name, gem_dir(gem_ref), loader_name + end + + def to_s() + "(ModuleLoader::GemBased '#{loader_name()}' '#{@gem_ref}' [#{module_name()}])" + end + end + +end \ No newline at end of file diff --git a/lib/puppet/pops/loader/null_loader.rb b/lib/puppet/pops/loader/null_loader.rb new file mode 100644 index 000000000..4a7a83db7 --- /dev/null +++ b/lib/puppet/pops/loader/null_loader.rb @@ -0,0 +1,44 @@ +# The null loader is empty and delegates everything to its parent if it has one. +# +class Puppet::Pops::Loader::NullLoader < Puppet::Pops::Loader::Loader + attr_reader :loader_name + + # Construct a NullLoader, optionally with a parent loader + # + def initialize(parent_loader=nil, loader_name = "null-loader") + @loader_name = loader_name + @parent = parent_loader + end + + # Has parent if one was set when constructed + def parent + @parent + end + + def load_typed(type, name) + if @parent.nil? + nil + else + @parent.load_typed(typed_name) + end + end + + # Has no entries on its own - always nil + def get_entry(typed_name) + nil + end + + # Finds nothing, there are no entries + def find(name) + nil + end + + # Cannot store anything + def set_entry(typed_name, value) + nil + end + + def to_s() + "(NullLoader '#{loader_name}')" + end +end \ No newline at end of file diff --git a/lib/puppet/pops/loader/puppet_function_instantiator.rb b/lib/puppet/pops/loader/puppet_function_instantiator.rb index 16e5b29f4..7fbab8079 100644 --- a/lib/puppet/pops/loader/puppet_function_instantiator.rb +++ b/lib/puppet/pops/loader/puppet_function_instantiator.rb @@ -1,97 +1,97 @@ # The PuppetFunctionInstantiator instantiates a Puppet::Functions::Function given a Puppet Programming language # source that when called evaluates the Puppet logic it contains. # class Puppet::Pops::Loader::PuppetFunctionInstantiator # Produces an instance of the Function class with the given typed_name, or fails with an error if the # given puppet source does not produce this instance when evaluated. # - # @param loader [T.B.D] The loader the function is associated with + # @param loader [Puppet::Pops::Loader::Loader] The loader the function is associated with # @param typed_name [Puppet::Pops::Loader::TypedName] the type / name of the function to load # @param source_ref [URI, String] a reference to the source / origin of the puppet code to evaluate # @param pp_code_string [String] puppet code in a string # # @return [Puppet::Pops::Functions.Function] - an instantiated function with global scope closure associated with the given loader # def self.create(loader, typed_name, source_ref, pp_code_string) parser = Puppet::Pops::Parser::EvaluatingParser.new() # parse and validate result = parser.parse_string(pp_code_string, source_ref) # Only one function is allowed (and no other definitions) case result.model.definitions.size when 0 raise ArgumentError, "The code loaded from #{source_ref} does not define the function #{typed_name.name} - it is empty." when 1 # ok else raise ArgumentError, "The code loaded from #{source_ref} must contain only the function #{typed_name.name} - it has additional definitions." end the_function_definition = result.model.definitions[0] - # TODO: There is no FunctionExpression yet + unless the_function_definition.is_a?(Puppet::Pops::Model::FunctionDefinition) raise ArgumentError, "The code loaded from #{source_ref} does not define the function #{typed_name.name} - no function found." end unless the_function_definition.name == typed_name.name expected = typed_name.name actual = the_function_definition.name raise ArgumentError, "The code loaded from #{source_ref} produced function with the wrong name, expected #{expected}, actual #{actual}" end unless result.model().body == the_function_definition raise ArgumentError, "The code loaded from #{source_ref} contains additional logic - can only contain the function #{typed_name.name}" end # TODO: Cheating wrt. scope - assuming it is found in the context closure_scope = Puppet.lookup(:global_scope) { {} } created = create_function_class(the_function_definition, closure_scope) # create the function instance - it needs closure (scope), and loader (i.e. where it should start searching for things # when calling functions etc. # It should be bound to global scope created.new(closure_scope, loader) end def self.create_function_class(function_definition, closure_scope) method_name = :"#{function_definition.name.split(/::/).slice(-1)}" closure = Puppet::Pops::Evaluator::Closure.new( Puppet::Pops::Evaluator::EvaluatorImpl.new(), function_definition, closure_scope) required_optional = function_definition.parameters.reduce([0, 0]) do |memo, p| if p.value.nil? memo[0] += 1 else memo[1] += 1 end memo end min_arg_count = required_optional[0] max_arg_count = required_optional[0] + required_optional[1] # Create a 4x function wrapper around the Puppet Function created_function_class = Puppet::Functions.create_function(function_definition.name) do # Define the method that is called from dispatch - this method just changes a call # with multiple unknown arguments to passing all in an array (since this is expected in the closure API. # # TODO: The closure will call the evaluator.call method which will again match args with parameters. # This can be done a better way later - unifying the two concepts - a function instance is really the same # as the current evaluator closure for lambdas, only that it also binds an evaluator. This could perhaps # be a specialization of Function... with a special dispatch # define_method(:__relay__call__) do |*args| closure.call(nil, *args) end # Define a dispatch that performs argument type/count checking # dispatch :__relay__call__ do # Use Puppet Type Object (not Optional[Object] since the 3x API passes undef as empty string). param(optional(object), 'args') # Specify arg count (transformed from FunctionDefinition.parameters, no types, or varargs yet) arg_count(min_arg_count, max_arg_count) end end created_function_class end end \ No newline at end of file diff --git a/lib/puppet/pops/loader/ruby_function_instantiator.rb b/lib/puppet/pops/loader/ruby_function_instantiator.rb index 175e916db..0b66ac126 100644 --- a/lib/puppet/pops/loader/ruby_function_instantiator.rb +++ b/lib/puppet/pops/loader/ruby_function_instantiator.rb @@ -1,34 +1,34 @@ # The RubyFunctionInstantiator instantiates a Puppet::Functions::Function given the ruby source # that calls Puppet::Functions.create_function. # class Puppet::Pops::Loader::RubyFunctionInstantiator # Produces an instance of the Function class with the given typed_name, or fails with an error if the # given ruby source does not produce this instance when evaluated. # - # @param loader [T.B.D] The loader the function is associated with + # @param loader [Puppet::Pops::Loader::Loader] The loader the function is associated with # @param typed_name [Puppet::Pops::Loader::TypedName] the type / name of the function to load # @param source_ref [URI, String] a reference to the source / origin of the ruby code to evaluate # @param ruby_code_string [String] ruby code in a string # # @return [Puppet::Pops::Functions.Function] - an instantiated function with global scope closure associated with the given loader # def self.create(loader, typed_name, source_ref, ruby_code_string) unless ruby_code_string.is_a?(String) && ruby_code_string =~ /Puppet\:\:Functions\.create_function/ raise ArgumentError, "The code loaded from #{source_ref} does not seem to be a Puppet 4x API function - no create_function call." end created = eval(ruby_code_string) unless created.is_a?(Class) raise ArgumentError, "The code loaded from #{source_ref} did not produce a Function class when evaluated. Got '#{created.class}'" end unless created.name.to_s == typed_name.name() raise ArgumentError, "The code loaded from #{source_ref} produced mis-matched name, expected '#{typed_name.name}', got #{created.name}" end # create the function instance - it needs closure (scope), and loader (i.e. where it should start searching for things # when calling functions etc. # It should be bound to global scope # TODO: Cheating wrt. scope - assuming it is found in the context closure_scope = Puppet.lookup(:global_scope) { {} } created.new(closure_scope, loader) end end \ No newline at end of file diff --git a/lib/puppet/pops/loader/ruby_legacy_function_instantiator.rb b/lib/puppet/pops/loader/ruby_legacy_function_instantiator.rb index cb7083d1d..a3661ebaa 100644 --- a/lib/puppet/pops/loader/ruby_legacy_function_instantiator.rb +++ b/lib/puppet/pops/loader/ruby_legacy_function_instantiator.rb @@ -1,109 +1,109 @@ # The RubyLegacyFunctionInstantiator loads a 3x function and turns it into a 4x function # that is called with 3x semantics (values are transformed to be 3x compliant). # # The code is loaded from a string obtained by reading the 3x function ruby code into a string # and then passing it to the loaders class method `create`. When Puppet[:biff] == true, the # 3x Puppet::Parser::Function.newfunction method relays back to this function loader's # class method legacy_newfunction which creates a Puppet::Functions class wrapping the # 3x function's block into a method in a function class derived from Puppet::Function. # This class is then returned, and the Legacy loader continues the same way as it does # for a 4x function. # # TODO: Wrapping of Scope # The 3x function expects itself to be Scope. It passes itself as scope to other parts of the runtime, # it expects to find all sorts of information in itself, get/set variables, get compiler, get environment # etc. # TODO: Transformation of arguments to 3x compliant objects # class Puppet::Pops::Loader::RubyLegacyFunctionInstantiator # Produces an instance of the Function class with the given typed_name, or fails with an error if the # given ruby source does not produce this instance when evaluated. # - # @param loader [T.B.D] The loader the function is associated with + # @param loader [Puppet::Pops::Loader::Loader] The loader the function is associated with # @param typed_name [Puppet::Pops::Loader::TypedName] the type / name of the function to load # @param source_ref [URI, String] a reference to the source / origin of the ruby code to evaluate # @param ruby_code_string [String] ruby code in a string # # @return [Puppet::Pops::Functions.Function] - an instantiated function with global scope closure associated with the given loader # def self.create(loader, typed_name, source_ref, ruby_code_string) # Old Ruby API supports calling a method via :: # this must also be checked as well as call with '.' # unless ruby_code_string.is_a?(String) && ruby_code_string =~ /Puppet\:\:Parser\:\:Functions(?:\.|\:\:)newfunction/ raise ArgumentError, "The code loaded from #{source_ref} does not seem to be a Puppet 3x API function - no newfunction call." end # The evaluation of the 3x function creation source should result in a call to the legacy_newfunction # created = eval(ruby_code_string) unless created.is_a?(Class) raise ArgumentError, "The code loaded from #{source_ref} did not produce a Function class when evaluated. Got '#{created.class}'" end unless created.name.to_s == typed_name.name() raise ArgumentError, "The code loaded from #{source_ref} produced mis-matched name, expected '#{typed_name.name}', got #{created.name}" end # create the function instance - it needs closure (scope), and loader (i.e. where it should start searching for things # when calling functions etc. # It should be bound to global scope # TODO: Cheating wrt. scope - assuming it is found in the context closure_scope = Puppet.lookup(:global_scope) { {} } created.new(closure_scope, loader) end # This is a new implementation of the method that is used in 3x to create a function. # The arguments are the same as those passed to Puppet::Parser::Functions.newfunction, hence its # deviation from regular method naming practice. # def self.legacy_newfunction(name, options, &block) # 3x api allows arity to be specified, if unspecified it is 0 or more arguments # arity >= 0, is an exact count # airty < 0 is the number of required arguments -1 (i.e. -1 is 0 or more) # (there is no upper cap, there is no support for optional values, or defaults) # arity = options[:arity] || -1 if arity >= 0 min_arg_count = arity max_arg_count = arity else min_arg_count = (arity + 1).abs # infinity max_arg_count = :default end # Create a 4x function wrapper around the 3x Function created_function_class = Puppet::Functions.create_function(name) do # define a method on the new Function class with the same name as the function, but # padded with __ because the function may represent a ruby method with the same name that # expects to have inherited from Kernel, and then Object. # (This can otherwise lead to infinite recursion, or that an ArgumentError is raised). # __name__ = :"__#{name}__" define_method(__name__, &block) # Define the method that is called from dispatch - this method just changes a call # with multiple unknown arguments to passing all in an array (since this is expected in the 3x API). # We want the call to be checked for type and number of arguments so cannot call the function # defined by the block directly since it is defined to take a single argument. # define_method(:__relay__call__) do |*args| # dup the args since the function may destroy them # TODO: Should convert arguments to 3x, now :undef is send to the function send(__name__, args.dup) end # Define a dispatch that performs argument type/count checking # dispatch :__relay__call__ do # Use Puppet Type Object (not Optional[Object] since the 3x API passes undef as empty string). param object, 'args' # Specify arg count (transformed from 3x function arity specification). arg_count(min_arg_count, max_arg_count) end end created_function_class end end diff --git a/lib/puppet/pops/loader/static_loader.rb b/lib/puppet/pops/loader/static_loader.rb new file mode 100644 index 000000000..88ba86d85 --- /dev/null +++ b/lib/puppet/pops/loader/static_loader.rb @@ -0,0 +1,32 @@ + # Static Loader contains constants, basic data types and other types required for the system + # to boot. + # +class Puppet::Pops::Loader::StaticLoader < Puppet::Pops::Loader::Loader + + def load_typed(typed_name) + load_constant(typed_name) + end + + def get_entry(typed_name) + load_constant(typed_name) + end + + def find(name) + # There is nothing to search for, everything this loader knows about is already available + nil + end + + def parent + nil # at top of the hierarchy + end + + def to_s() + "(StaticLoader)" + end + private + + def load_constant(typed_name) + # Move along, nothing to see here a.t.m... + nil + end +end diff --git a/lib/puppet/pops/loader/uri_helper.rb b/lib/puppet/pops/loader/uri_helper.rb new file mode 100644 index 000000000..ff42a60af --- /dev/null +++ b/lib/puppet/pops/loader/uri_helper.rb @@ -0,0 +1,22 @@ +module Puppet::Pops::Loader::UriHelper + # Raises an exception if specified gem can not be located + # + def path_for_uri(uri, subdir='lib') + case uri.scheme + when "gem" + begin + spec = Gem::Specification.find_by_name(uri.hostname) + # if path given append that, else append given subdir + File.join(spec.gem_dir, uri.path.empty?() ? subdir : uri.path) + rescue StandardError => e + raise "TODO TYPE: Failed to located gem #{uri}. #{e.message}" + end + when "file" + File.join(uri.path, subdir) + when nil + File.join(uri.path, subdir) + else + raise "Not a valid scheme for a loader: #{uri.scheme}. Use a 'file:' (or just a path), or 'gem://gemname[/path]" + end + end +end diff --git a/lib/puppet/pops/loaders.rb b/lib/puppet/pops/loaders.rb new file mode 100644 index 000000000..7e09f59be --- /dev/null +++ b/lib/puppet/pops/loaders.rb @@ -0,0 +1,102 @@ +class Puppet::Pops::Loaders + + attr_reader :static_loader + attr_reader :puppet_system_loader + attr_reader :environment_loader + + def initialize() + # The static loader can only be changed after a reboot + @@static_loader ||= Puppet::Pops::Loader::StaticLoader.new() + + # Create the set of loaders + # 1. Puppet, loads from the "running" puppet - i.e. bundled functions, types, extension points and extensions + # Does not change without rebooting the service running puppet. + # + @@puppet_system_loader ||= create_puppet_system_loader() + + # 2. Environment loader - i.e. what is bound across the environment, may change for each setup + # TODO: loaders need to work when also running in an agent doing catalog application. There is no + # concept of environment the same way as when running as a master (except when doing apply). + # The creation mechanisms should probably differ between the two. + # + @environment_loader = create_environment_loader() + + # 3. module loaders are set up from the create_environment_loader, they register themselves + end + + # Clears the cached static and puppet_system loaders (to enable testing) + # + def self.clear + @@static_loader = nil + @@puppet_system_loader = nil + end + + def static_loader + @@static_loader + end + + def puppet_system_loader + @@puppet_system_loader + end + + def self.create_loaders() + self.new() + end + + private + + def create_puppet_system_loader() + module_name = nil + loader_name = 'puppet_system' + + # Puppet system may be installed in a fixed location via RPM, installed as a Gem, via source etc. + # The only way to find this across the different ways puppet can be installed is + # to search up the path from this source file's __FILE__ location until it finds the parent of + # lib/puppet... e.g.. dirname(__FILE__)/../../.. (i.e. /lib/puppet/pops/loaders.rb). + # + puppet_lib = File.join(File.dirname(__FILE__), '../../..') + Puppet::Pops::Loader::ModuleLoaders::FileBased.new(static_loader, module_name, puppet_lib, loader_name) + end + + def create_environment_loader() + # This defines where to start parsing/evaluating - the "initial import" (to use 3x terminology) + # Is either a reference to a single .pp file, or a directory of manifests. If the environment becomes + # a module and can hold functions, types etc. then these are available across all other modules without + # them declaring this dependency - it is however valuable to be able to treat it the same way + # bindings and other such system related configuration. + + # This is further complicated by the many options available: + # - The environment may not have a directory, the code comes from one appointed 'manifest' (site.pp) + # - The environment may have a directory and also point to a 'manifest' + # - The code to run may be set in settings (code) + + # Further complication is that there is nothing specifying what the visibility is into + # available modules. (3x is everyone sees everything). + # Puppet binder currently reads confdir/bindings - that is bad, it should be using the new environment support. + + current_environment = Puppet.lookup(:current_environment) + # The environment is not a namespace, so give it a nil "module_name" + module_name = nil + loader_name = "environment:#{current_environment.name}" + env_dir = Puppet[:environmentdir] + if env_dir.nil? + loader = Puppet::Pops::Loader::NullLoader.new(puppet_system_loader, loader_name) + else + envdir_path = File.join(env_dir, current_environment.name.to_s) + # TODO: Representing Environment as a Module - needs something different (not all types are supported), + # and it must be able to import .pp code from 3x manifest setting, or from code setting as well as from + # a manifests directory under the environment's root. The below is cheating... + # + loader = Puppet::Pops::Loader::ModuleLoaders::FileBased(puppet_system_loader, module_name, envdir_path, loader_name) + end + # An environment has a module path even if it has a null loader + configure_loaders_for_modulepath(loader, current_environment.modulepath) + loader + end + + def configure_loaders_for_modulepath(loader, modulepath) + # TODO: For each module on the modulepath, create a lazy loader + # TODO: Register the module's external and internal loaders (the loader for the module itself, and the loader + # for its dependencies. + end +end \ No newline at end of file diff --git a/spec/unit/pops/loaders/dependency_loader_spec.rb b/spec/unit/pops/loaders/dependency_loader_spec.rb new file mode 100644 index 000000000..32d7c403c --- /dev/null +++ b/spec/unit/pops/loaders/dependency_loader_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' +require 'puppet_spec/files' +require 'puppet/pops' +require 'puppet/loaders' + +describe 'dependency loader' do + include PuppetSpec::Files + + let(:static_loader) { Puppet::Pops::Loader::StaticLoader.new() } + + describe 'FileBased module loader' do + it 'can load something in global name space from module it depends on' do + module_dir = dir_containing('testmodule', { + 'functions' => { + 'foo.pp' => 'function foo() { yay }'}}) + + module_loader = Puppet::Pops::Loader::ModuleLoaders::FileBased.new(static_loader, 'testmodule', module_dir, 'test1') + dep_loader = Puppet::Pops::Loader::DependencyLoader.new(static_loader, 'test-dep', [module_loader]) + function = dep_loader.load_typed(typed_name(:function, 'foo')).value + expect(function.class.name).to eq('foo') + expect(function.is_a?(Puppet::Functions::Function)).to eq(true) + end + + it 'can load something in a qualified name space' do + module_dir = dir_containing('testmodule', { + 'functions' => { + 'foo.pp' => 'function testmodule::foo() { yay }'}}) + + module_loader = Puppet::Pops::Loader::ModuleLoaders::FileBased.new(static_loader, 'testmodule', module_dir, 'test1') + dep_loader = Puppet::Pops::Loader::DependencyLoader.new(static_loader, 'test-dep', [module_loader]) + function = dep_loader.load_typed(typed_name(:function, 'testmodule::foo')).value + expect(function.class.name).to eq('testmodule::foo') + expect(function.is_a?(Puppet::Functions::Function)).to eq(true) + end + end + + def typed_name(type, name) + Puppet::Pops::Loader::Loader::TypedName.new(type, name) + end +end diff --git a/spec/unit/pops/loaders/loader_paths_spec.rb b/spec/unit/pops/loaders/loader_paths_spec.rb new file mode 100644 index 000000000..b732b134d --- /dev/null +++ b/spec/unit/pops/loaders/loader_paths_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' +require 'puppet_spec/files' +require 'puppet/pops' +require 'puppet/loaders' + +describe 'loader paths' do + include PuppetSpec::Files + + let(:static_loader) { Puppet::Pops::Loader::StaticLoader.new() } + + it 'expects dir_containing to create a temp directory structure from a hash' do + module_dir = dir_containing('testmodule', { 'test.txt' => 'Hello world', 'sub' => { 'foo.txt' => 'foo'}}) + expect(File.read(File.join(module_dir, 'test.txt'))).to be_eql('Hello world') + expect(File.read(File.join(module_dir, 'sub', 'foo.txt'))).to be_eql('foo') + end + + describe 'the relative_path_for_types method' do + it 'produces paths to load in precendence order' do + module_dir = dir_containing('testmodule', { + 'functions' => {}, + 'lib' => { + 'puppet' => { + 'functions' => {}, + 'parser' => { + 'functions' => {}, + } + }}}) + # Must have a File/Path based loader to talk to + module_loader = Puppet::Pops::Loader::ModuleLoaders::FileBased.new(static_loader, 'testmodule', module_dir, 'test1') + effective_paths = Puppet::Pops::Loader::LoaderPaths.relative_paths_for_type(:function, module_loader) + expect(effective_paths.size).to be_eql(3) + # 4x + expect(effective_paths[0].generic_path).to be_eql(File.join(module_dir, 'lib', 'puppet', 'functions')) + # 3x + expect(effective_paths[1].generic_path).to be_eql(File.join(module_dir, 'lib', 'puppet','parser', 'functions')) + # .pp + expect(effective_paths[2].generic_path).to be_eql(File.join(module_dir, 'functions')) + end + + it 'module loader has smart-paths that prunes unavailable paths' do + module_dir = dir_containing('testmodule', {'functions' => {'foo.pp' => 'function foo() { yay }'} }) + # Must have a File/Path based loader to talk to + module_loader = Puppet::Pops::Loader::ModuleLoaders::FileBased.new(static_loader, 'testmodule', module_dir, 'test1') + effective_paths = module_loader.smart_paths.effective_paths(:function) + expect(effective_paths.size).to be_eql(1) + expect(effective_paths[0].generic_path).to be_eql(File.join(module_dir, 'functions')) + expect(module_loader.path_index.size).to be_eql(1) + expect(module_loader.path_index.include?(File.join(module_dir, 'functions', 'foo.pp'))).to be(true) + end + + it 'all function smart-paths produces entries if they exist' do + module_dir = dir_containing('testmodule', { + 'functions' => {'foo.pp' => 'function foo() { yay }'}, + 'lib' => { + 'puppet' => { + 'functions' => {'foo4x.rb' => 'ignored in this test'}, + 'parser' => { + 'functions' => {'foo3x.rb' => 'ignored in this test'}, + } + }}}) + # Must have a File/Path based loader to talk to + module_loader = Puppet::Pops::Loader::ModuleLoaders::FileBased.new(static_loader, 'testmodule', module_dir, 'test1') + effective_paths = module_loader.smart_paths.effective_paths(:function) + expect(effective_paths.size).to eq(3) + expect(module_loader.path_index.size).to eq(3) + path_index = module_loader.path_index + expect(path_index.include?(File.join(module_dir, 'functions', 'foo.pp'))).to eq(true) + expect(path_index.include?(File.join(module_dir, 'lib', 'puppet', 'functions', 'foo4x.rb'))).to eq(true) + expect(path_index.include?(File.join(module_dir, 'lib', 'puppet', 'parser', 'functions', 'foo3x.rb'))).to eq(true) + end + end + +end diff --git a/spec/unit/pops/loaders/loaders_spec.rb b/spec/unit/pops/loaders/loaders_spec.rb new file mode 100644 index 000000000..cc58b7bb6 --- /dev/null +++ b/spec/unit/pops/loaders/loaders_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' +require 'puppet/pops' +require 'puppet/loaders' + +describe 'loaders' do + # Loaders caches the puppet_system_loader, must reset between tests + # + before(:each) { Puppet::Pops::Loaders.clear() } + + it 'creates a puppet_system loader' do + loaders = Puppet::Pops::Loaders.new() + expect(loaders.puppet_system_loader().class).to be(Puppet::Pops::Loader::ModuleLoaders::FileBased) + end + + it 'creates an environment loader' do + loaders = Puppet::Pops::Loaders.new() + # When this test is running, there is no environments dir configured, and a NullLoader is therefore used a.t.m + expect(loaders.environment_loader().class).to be(Puppet::Pops::Loader::NullLoader) + # The default name of the enironment is '*root*', and the loader should identify itself that way + expect(loaders.environment_loader().to_s).to eql("(NullLoader 'environment:*root*')") + end + + context 'when delegating 3x to 4x' do + before(:each) { Puppet[:biff] = true } + + it 'the puppet system loader can load 3x functions' do + loaders = Puppet::Pops::Loaders.new() + puppet_loader = loaders.puppet_system_loader() + function = puppet_loader.load_typed(typed_name(:function, 'sprintf')).value + expect(function.class.name).to eq('sprintf') + expect(function.is_a?(Puppet::Functions::Function)).to eq(true) + end + end + + # TODO: LOADING OF MODULES ON MODULEPATH + + def typed_name(type, name) + Puppet::Pops::Loader::Loader::TypedName.new(type, name) + end +end \ No newline at end of file diff --git a/spec/unit/pops/loaders/module_loaders_spec.rb b/spec/unit/pops/loaders/module_loaders_spec.rb new file mode 100644 index 000000000..89b76b95a --- /dev/null +++ b/spec/unit/pops/loaders/module_loaders_spec.rb @@ -0,0 +1,132 @@ +require 'spec_helper' +require 'puppet_spec/files' +require 'puppet/pops' +require 'puppet/loaders' + +describe 'module loaders' do + include PuppetSpec::Files + + let(:static_loader) { Puppet::Pops::Loader::StaticLoader.new() } + + describe 'FileBased module loader' do + it 'can load a .pp function in global name space' do + module_dir = dir_containing('testmodule', { + 'functions' => { + 'foo.pp' => 'function foo() { yay }'}}) + + module_loader = Puppet::Pops::Loader::ModuleLoaders::FileBased.new(static_loader, 'testmodule', module_dir, 'test1') + function = module_loader.load_typed(typed_name(:function, 'foo')).value + expect(function.class.name).to eq('foo') + expect(function.is_a?(Puppet::Functions::Function)).to eq(true) + end + + it 'can load a .pp function in a qualified name space' do + module_dir = dir_containing('testmodule', { + 'functions' => { + 'foo.pp' => 'function testmodule::foo() { yay }'}}) + + module_loader = Puppet::Pops::Loader::ModuleLoaders::FileBased.new(static_loader, 'testmodule', module_dir, 'test1') + function = module_loader.load_typed(typed_name(:function, 'testmodule::foo')).value + expect(function.class.name).to eq('testmodule::foo') + expect(function.is_a?(Puppet::Functions::Function)).to eq(true) + end + + it 'can load a 4x function API ruby function in global name space' do + module_dir = dir_containing('testmodule', { + 'lib' => { + 'puppet' => { + 'functions' => { + 'foo4x.rb' => <<-CODE + Puppet::Functions.create_function(:foo4x) do + def foo4x() + 'yay' + end + end + CODE + } + } + } + }) + + module_loader = Puppet::Pops::Loader::ModuleLoaders::FileBased.new(static_loader, 'testmodule', module_dir, 'test1') + function = module_loader.load_typed(typed_name(:function, 'foo4x')).value + expect(function.class.name).to eq('foo4x') + expect(function.is_a?(Puppet::Functions::Function)).to eq(true) + end + + it 'can load a 4x function API ruby function in qualified name space' do + module_dir = dir_containing('testmodule', { + 'lib' => { + 'puppet' => { + 'functions' => { + 'foo4x.rb' => <<-CODE + Puppet::Functions.create_function('testmodule::foo4x') do + def foo4x() + 'yay' + end + end + CODE + } + } + } + }) + + module_loader = Puppet::Pops::Loader::ModuleLoaders::FileBased.new(static_loader, 'testmodule', module_dir, 'test1') + function = module_loader.load_typed(typed_name(:function, 'testmodule::foo4x')).value + expect(function.class.name).to eq('testmodule::foo4x') + expect(function.is_a?(Puppet::Functions::Function)).to eq(true) + end + + it 'makes parent loader win over entries in child' do + module_dir = dir_containing('testmodule', { + 'functions' => { + 'foo.pp' => 'function foo() { yay }'}}) + + module_loader = Puppet::Pops::Loader::ModuleLoaders::FileBased.new(static_loader, 'testmodule', module_dir, 'test1') + module_dir2 = dir_containing('testmodule2', { + 'functions' => { + 'foo.pp' => 'fail(should not happen)'}}) + + module_loader2 = Puppet::Pops::Loader::ModuleLoaders::FileBased.new(module_loader, 'testmodule2', module_dir2, 'test2') + function = module_loader2.load_typed(typed_name(:function, 'foo')).value + expect(function.class.name).to eq('foo') + expect(function.is_a?(Puppet::Functions::Function)).to eq(true) + end + + context 'when delegating 3x to 4x' do + before(:each) { Puppet[:biff] = true } + + it 'can load a 3x function API ruby function in global name space' do + module_dir = dir_containing('testmodule', { + 'lib' => { + 'puppet' => { + 'parser' => { + 'functions' => { + 'foo3x.rb' => <<-CODE + Puppet::Parser::Functions::newfunction( + :foo3x, :type => :rvalue, + :arity => 1, + ) do |args| + args[0] + end + CODE + } + } + } + }}) + + module_loader = Puppet::Pops::Loader::ModuleLoaders::FileBased.new(static_loader, 'testmodule', module_dir, 'test1') + function = module_loader.load_typed(typed_name(:function, 'foo3x')).value + expect(function.class.name).to eq('foo3x') + expect(function.is_a?(Puppet::Functions::Function)).to eq(true) + end + end + + # Gives error when loading something with mismatched name + + end + + def typed_name(type, name) + Puppet::Pops::Loader::Loader::TypedName.new(type, name) + end +end diff --git a/spec/unit/pops/loaders/static_loader_spec.rb b/spec/unit/pops/loaders/static_loader_spec.rb new file mode 100644 index 000000000..6d8f516a6 --- /dev/null +++ b/spec/unit/pops/loaders/static_loader_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' +require 'puppet/pops' +require 'puppet/loaders' + +describe 'static loader' do + it 'has no parent' do + expect(Puppet::Pops::Loader::StaticLoader.new.parent).to be(nil) + end + + it 'identifies itself in string form' do + expect(Puppet::Pops::Loader::StaticLoader.new.to_s).to be_eql('(StaticLoader)') + end + + it 'support the Loader API' do + # it may produce things later, this is just to test that calls work as they should - now all lookups are nil. + loader = Puppet::Pops::Loader::StaticLoader.new() + a_typed_name = typed_name(:function, 'foo') + expect(loader[a_typed_name]).to be(nil) + expect(loader.load_typed(a_typed_name)).to be(nil) + expect(loader.find(a_typed_name)).to be(nil) + end + + def typed_name(type, name) + Puppet::Pops::Loader::Loader::TypedName.new(type, name) + end +end \ No newline at end of file