damphyr/gaudi

View on GitHub
lib/gaudi/helpers/configuration.rb

Summary

Maintainability
A
2 hrs
Test Coverage
require_relative "../version"
require_relative "errors"
require_relative "environment"

require "pathname"
require "yaml"

module Gaudi
  # The path to the defualt configuration file relative to the main rakefile
  DEFAULT_CONFIGURATION_FILE = File.expand_path(File.join(File.dirname(__FILE__), "../../../system.cfg"))
  # Loads and returns the system configuration
  def self.configuration
    if ENV["GAUDI_CONFIG"]
      ENV["GAUDI_CONFIG"] = File.expand_path(ENV["GAUDI_CONFIG"])
    else
      raise "No configuration file (GAUDI_CONFIG is initially empty and #{DEFAULT_CONFIGURATION_FILE} is missing)" unless File.exist?(DEFAULT_CONFIGURATION_FILE)

      ENV["GAUDI_CONFIG"] = File.expand_path(DEFAULT_CONFIGURATION_FILE)
    end
    # Load the system configuration
    puts "Reading main configuration from \n\t#{ENV["GAUDI_CONFIG"]}"
    system_config = Configuration::SystemConfiguration.new(ENV["GAUDI_CONFIG"])
    return system_config
  end

  ##
  # Module of methods and classes for handling the parsing, extensions and
  # switching of Gaudi configuration
  module Configuration
    ##
    # Module of methods facilitating the handling of configuration entries
    module Helpers
      ##
      # Check if the file +fname+ exists and return its absolute path
      #
      # A GaudiConfigurationError is raised if +fname+ is invalid or does not
      # point to an existing file.
      def required_path(fname)
        raise GaudiConfigurationError, "Empty value for required path" unless fname && !fname.empty?

        raise GaudiConfigurationError, "Missing required file #{fname}" unless File.exist?(fname)

        return File.expand_path(fname)
      end
    end

    # Switches the configuration for the given block
    #
    # It switches the system configuration by reading a completely different file.
    #
    # This makes for some interesting usage when you don't want to have multiple calls
    # with a different GAUDI_CONFIG parameter
    def self.switch_configuration(configuration_file)
      return unless block_given?

      current_configuration = ENV["GAUDI_CONFIG"]
      return unless File.expand_path(configuration_file) != File.expand_path(current_configuration)

      begin
        puts "Switching configuration to #{configuration_file}"
        $configuration = nil
        ENV["GAUDI_CONFIG"] = configuration_file
        $configuration = Gaudi::Configuration::SystemConfiguration.load([ENV["GAUDI_CONFIG"]])
        yield
      ensure
        puts "Switching configuration back to #{current_configuration}"
        $configuration = nil
        ENV["GAUDI_CONFIG"] = current_configuration
        $configuration = Gaudi::Configuration::SystemConfiguration.load([ENV["GAUDI_CONFIG"]])
      end
    end

    # Class to load the configuration from a key=value textfile with a few additions.
    #
    # Configuration classes derived from Loader override the Loader#keys method to specify characteristics for the keys.
    #
    # Paths are interpreted relative to the configuration file.
    #
    # In addition to the classic property format (key=value) Loader allows you to split the configuration in multiple files
    # and use import to put them together:
    # import additional.cfg
    #
    # Also use setenv to set the value of an environment variable
    #
    # setenv GAUDI=brilliant builder
    class Loader
      include Helpers

      ##
      # Iterate through a list of configuration files +configuration_files+ and
      # return the resulting configuration as a new instance of class +klass+
      #
      # In case of errors upon parsing the given configurations or instantiating
      # the new +klass+ instance a GaudiConfigurationError is to be raised.
      #
      # The class +klass+ should generally be a derivative of Loader.
      def self.load(configuration_files, klass)
        cfg = nil
        configuration_files.each do |cfg_file|
          if cfg
            cfg.merge(cfg_file)
          else
            cfg = klass.new(cfg_file)
          end
        end
        raise GaudiConfigurationError, "No #{klass.to_s} configuration files in '#{configuration_files}'" unless cfg

        return cfg
      end

      # Returns all the configuration files processed (e.g. those read with import and the platform configuration files)
      # The main file (the one used in initialize) is always first in the collection
      attr_reader :configuration_files
      ##
      # A hash of the configuration files' key-value pairs
      attr_reader :config

      ##
      # Initialize a new instance by loading the configuration from +filename+
      def initialize(filename)
        @configuration_files = [File.expand_path(filename)]
        @config = read_configuration(File.expand_path(filename))
      end

      # Returns an Array containing two arrays.
      #
      # Override in Loader derived classes.
      #
      # The first one lists the names of all keys that correspond to list values (comma separated values, e.g. key=value1,value2,value3)
      # Loader will assign an Array of the values to the key, e.g. config[key]=[value1,value2,value3]
      #
      # The second Array is a list of keys that correspond to pathnames.
      #
      # Loader will then expand_path the value so that the key contains the absolute path.
      def keys
        return [], []
      end

      ##
      # Print informative string about the version of the underlying Gaudi and
      # the main configuration file
      def to_s
        "Gaudi #{Gaudi::Version::STRING} with #{configuration_files.first}"
      end

      # Merges the parameters from cfg_file into this instance
      def merge(cfg_file)
        cfg = read_configuration(cfg_file)
        list_keys, = *keys
        cfg.each_key do |k|
          if @config.keys.include?(k) && list_keys.include?(k)
            @config[k] += cfg[k]
          else
            # last one wins
            @config[k] = cfg[k]
          end
        end
        @configuration_files << cfg_file
      end

      ##
      # Read a configuration file +filename+ and returns its contents as a hash
      #
      # Returns a hash of the configuration file's interpreted contents as
      # key-value pairs
      def read_configuration(filename)
        raise GaudiConfigurationError, "Cannot load configuration.'#{filename}' not found" unless File.exist?(filename)

        lines = File.readlines(filename)
        cfg_dir = File.dirname(filename)
        begin
          cfg = parse_content(lines, cfg_dir, *keys)
        rescue GaudiConfigurationError
          raise GaudiConfigurationError, "In #{filename} - #{$!.message}"
        end

        return cfg
      end

      private

      ##
      # Parse the contents of a configuration file and return its interpreted
      # key-value combinations as a hash
      #
      # * +lines+ - the entire content of the configuration file as its
      #   individual lines
      # * +cfg_dir+ - the path of the directory the configuration file resides
      #   in
      # * +list_keys+ - a list of all keys which represent comma-separated lists
      #   of values
      # * +path_keys+ - a list of all keys representing paths
      #
      # Returns the interpreted contents of the configuration file as a hash
      def parse_content(lines, cfg_dir, list_keys, path_keys)
        cfg = {}
        lines.each do |l|
          l.gsub!("\t", "")
          l.chomp!
          # ignore if it starts with a hash
          unless l =~ /^#/ || l.empty?
            if /^setenv\s+(?<envvar>.+?)\s*=\s*(?<val>.*)/ =~ l
              environment_variable(envvar, val)
              # if it starts with an import get a new config file
            elsif /^import\s+(?<path>.*)/ =~ l
              cfg.merge!(import_config(path, cfg_dir))
            elsif /^(?<key>.*?)\s*\+=\s*(?<v>.*)/ =~ l
              handle_key_append(key, v, cfg_dir, list_keys, path_keys, cfg)
            elsif /^(?<key>.*?)\s*=\s*(?<v>.*)/ =~ l
              cfg[key] = handle_key(key, v, cfg_dir, list_keys, path_keys, cfg)
            else
              raise GaudiConfigurationError, "Syntax error: '#{l}'"
            end
          end
        end
        return cfg
      end

      ##
      # Append +value+ to a +key+ that is defined as a key representing
      # comma-separated lists
      #
      # For a detailed explanation of the arguments see #handle_key.
      #
      # Returns the combination of +value+ and potentially already existing
      # elements of +key+ as an array
      def handle_key_append(key, value, cfg_dir, list_keys, path_keys, cfg)
        this_value = handle_key(key, value, cfg_dir, list_keys, path_keys, cfg)
        cfg[key] = [] unless cfg.has_key?(key)

        begin
          cfg[key].concat(this_value).uniq!
        rescue NoMethodError
        end
      end

      ##
      # Interpret a +value+ of a certain +key+ according to further information
      #
      # * +key+ - the key of the value which is to be interpreted
      # * +value+ - the value which is to be interpreted
      # * +cfg_dir+ - the directory the configuration file of +key+ is
      #   contained in
      # * +list_keys+ - a list of all keys which represent comma-separated lists
      #   of values
      # * +path_keys+ - a list of all keys representing paths
      # * +cfg+ - a hash of all configuration key-value combinations parsed so
      #   far
      #
      # The +value+ of a +key+ that is found in +list_keys+ is being returned as
      # an array, while a +value+ of a +key+ in +path_keys+ is returned as
      # absolute path. If +key+ belongs to both categories its +value+ is being
      # treated accordingly.
      #
      # Returns the +value+ optionally being transformed according to the
      # additionally provided information
      def handle_key(key, value, cfg_dir, list_keys, path_keys, cfg)
        final_value = value
        # replace %{symbol} with values from already existing config values
        final_value = final_value % Hash[cfg.map { |k, v| [k.to_sym, v] }] if final_value.include? "%{"

        if list_keys.include?(key) && path_keys.include?(key)
          final_value = Rake::FileList[*(value.gsub(/\s*,\s*/, ",").split(",").uniq.map { |d| absolute_path(d.strip, cfg_dir) })]
        elsif list_keys.include?(key)
          # here we want to handle a comma separated list of entries
          final_value = value.gsub(/\s*,\s*/, ",").split(",").uniq
        elsif path_keys.include?(key)
          final_value = absolute_path(value.strip, cfg_dir)
        end
        return final_value
      end

      ##
      # Read a configuration file +path+ which may reside in +cfg_dir+
      #
      # If +path+ is an absolute path, then +cfg_dir+ is being ignored.
      # Otherwise +path+ is prefixed with +cfg_dir+.
      #
      # Returns a hash with the configuration file's read and parsed contents
      def import_config(path, cfg_dir)
        path = absolute_path(path.strip, cfg_dir)
        raise GaudiConfigurationError, "Cannot find #{path} to import" unless File.exist?(path)

        @configuration_files << path
        read_configuration(path)
      end

      ##
      # Check if a +path+ is absolute and prepend it with +cfg_dir+ otherwise
      #
      # If +path+ is an absolute path already it's being returned unmodified. If
      # it does not represent an absolute path it's being prefixed with
      # +cfg_dir+ and then being converted to an absolute path.
      #
      # Any double quotes are being removed from the passed +path+.
      #
      # Returns the absolute and conditionally prefixed equivalent of the path
      def absolute_path(path, cfg_dir)
        if Pathname.new(path.gsub("\"", "")).absolute?
          path
        else
          File.expand_path(File.join(cfg_dir, path.gsub("\"", "")))
        end
      end

      ##
      # Create or update an environment variable +envvar+ with the value +value+
      #
      # Returns the +value+ that got set
      def environment_variable(envvar, value)
        ENV[envvar] = value
      end

      ##
      # Iterate over all classes and sub-modules within +module_const+ and
      # accumulate all keys representing lists and all keys representing paths
      #
      # Returns an array containing an array of all keys representing
      # comma-separated lists and an array of all keys representing paths
      def load_key_modules(module_const)
        list_keys = []
        path_keys = []

        configuration_modules = module_const.constants
        # include all the modules you find in the namespace
        configuration_modules.each do |mod|
          klass = module_const.const_get(mod)
          extend klass
          list_keys += klass.list_keys
          path_keys += klass.path_keys
        end

        return list_keys, path_keys
      end
    end

    # The central configuration for the system
    #
    # The available functionality is extended through SystemModules modules
    class SystemConfiguration < Loader
      include EnvironmentOptions

      ##
      # Iterate through a list of configuration files +configuration_files+ and
      # return the resulting configuration as a new SystemConfiguration instance
      #
      # In case of errors upon parsing the given configurations or instantiating
      # the new instance a GaudiConfigurationError is to be raised.
      #
      # Returns a new SystemConfiguration instance
      def self.load(configuration_files)
        super(configuration_files, self)
      end

      ##
      # The main configuration file (by which the further ones were imported)
      attr_accessor :config_base
      ##
      # The current time at the completion of the instance's initialization
      attr_accessor :timestamp
      ##
      # The path of the current working directory of the process at the time of
      # the instance's initialization
      attr_accessor :workspace

      def initialize(filename)
        load_gaudi_modules(File.expand_path(filename))
        super(filename)
        @config_base = File.dirname(configuration_files.first)
        raise GaudiConfigurationError, "Setting 'base' must be defined" unless base
        raise GaudiConfigurationError, "Setting 'out' must be defined" unless out

        @workspace = Dir.pwd
        @timestamp = Time.now
      end

      def keys
        load_key_modules(Gaudi::Configuration::SystemModules)
      end

      private

      # makes sure we require the helpers from any modules defined in the configuration
      # before we start reading the configuration to ensure that extension modules work correctly
      def load_gaudi_modules(main_config_file)
        lines = File.readlines(main_config_file)
        relevant_lines = lines.select do |ln|
          /base=/ =~ ln || /gaudi_modules=/ =~ ln
        end
        cfg = parse_content(relevant_lines, File.dirname(main_config_file), *keys)
        require_modules(cfg.fetch("gaudi_modules", []), cfg["base"])
      end

      # Iterates over system_config.gaudi_modules and requires all helper files
      def require_modules(module_list, base_directory)
        module_list.each do |gm|
          mass_require(Rake::FileList["#{base_directory}/tools/build/lib/#{gm}/helpers/*.rb"])
          mass_require(Rake::FileList["#{base_directory}/tools/build/lib/#{gm}/rules/*.rb"])
        end
      end
    end

    # Adding modules in this module allows SystemConfiguration to extend it's functionality
    #
    # Modules must implement two methods:
    # list_keys returning an Array with the keys that are comma separated lists
    # and path_keys returning an Array with the keys whose value is a file path
    #
    # Modules are guaranteed a @config Hash providing access to the configuration file contents
    module SystemModules
      # The absolute basics for configuration
      module BaseConfiguration
        # :stopdoc:
        def self.list_keys
          ["gaudi_modules"]
        end

        def self.path_keys
          ["base", "out"]
        end

        # :startdoc:
        # The root path.
        # Every path in the system can be defined relative to this
        def base
          return @config["base"]
        end

        # The output directory
        def out
          return @config["out"]
        end

        # A list of module names (directories) to automatically require next to core when loading Gaudi
        def gaudi_modules
          @config["gaudi_modules"] ||= []
          return @config["gaudi_modules"]
        end

        alias_method :base_dir, :base
        alias_method :out_dir, :out
      end
    end
  end
end