# encoding: utf-8 require 'fedux_org_stdlib/require_files' require 'fedux_org_stdlib/app_config/exceptions' require 'fedux_org_stdlib/process_environment' require 'fedux_org_stdlib/core_ext/array/list' require 'fedux_org_stdlib/core_ext/hash/list' require 'fedux_org_stdlib/logging/logger' require_library %w(json psych active_support/core_ext/string/inflections set active_support/core_ext/hash/slice active_support/core_ext/object/blank active_support/core_ext/hash/keys) module FeduxOrgStdlib # This class makes a config file available as an object. The config file # needs to be `YAML` by default. It is read by `Psych` and converted to a # hash. If you chose to use a different file format: Each config file needs # to translatable to a hash or another data structure which responds to # `[]` by the given `config_engine`. If no suitable config file can be found # the config uses only the defined defaults within the class. # # By default it will look for a suitable config file in the given order: # # 1. $HOME/.config//.yaml # 2. $HOME/./.yaml # 2. $HOME/..yaml # 2. $HOME/.rc # 3. /etc/./.yaml # # Please keep in mind # # * application_name: Module of your class, e.g. "MyApplication" becomes # "my_application" # * config_file: Pluarized name of your class and "Config" strip # off, e.g "ClientConfig" becomes "clients.yaml" (mind the pluralized name) # # Most conventions defined by me are implemented as separate methods. If one convention # is not suitable for your use case, just overwrite the method. # # If you prefer to use a different path to the config file or name of the # config file one of the following methods needs to be overwritten: # # * config_file # * config_name # * application_name # # If you want the class to look for your config file at a different place # overwrite the following method # # * allowed_config_file_paths # # Below you find some examples for the usage of the class: # # @example Create config with one writer and reader # module MyApplication # class ClientConfig < AppConfig # # 'data1' is the default for option1 # # if you create a file # option :option1, 'data1' # end # end # # @example Create config with a reader only # module MyApplication # class ClientConfig < AppConfig # # 'data1' is the default for option1 # # if you create a file # option_reader :option1, 'data1' # end # end # # @example Config yaml file for the classes above: clients.yaml # --- # option1: 'data2' class AppConfig class << self # @api private def known_options @options ||= Set.new end # Get access to process environment # # This might be handy to define default options # # @example Get variable # # process_environment.fetch('HOME') # # => ENV['HOME'] def process_environment @process_environment ||= ProcessEnvironment.new end # Define a reader for option # # @param [Symbol] option # Name of option # # @param [Object] default_value # The default value of this option def option_reader(option, default_value) option = option.to_sym fail Exceptions::OptionNameForbidden, JSON.dump(option: option) if _reserved_key_words.include? option define_method option do _config.fetch(option, default_value) end known_options << option end # Define a writer for option # # Please make sure that you define a reader as well. Otherwise you cannot # access the option. Under normal conditions it does not make sense to # use this method. # # @api private # # @param [Symbol] option # Name of option # # @raise [Exceptions::ConfigLocked] # If one tries to modified a locked config def option_writer(option) fail Exceptions::OptionNameForbidden, JSON.dump(option: option) if _reserved_key_words.include? "#{option}=".to_sym define_method "#{option}=".to_sym do |value| begin _config[option.to_sym] = value rescue RuntimeError raise Exceptions::ConfigLocked end end known_options << option end # Define a writer and a reader for option # # @param [Symbol] option # Name of option # # @param [Object] default_value # Default value of option def option(option, default_value) option_reader(option, default_value) option_writer(option) end end private # Holds the config # rubocop:disable Style/TrivialAccessors def _config @__config end # rubocop:enable Style/TrivialAccessors public # Create a new instance of config # # It tries to find a suitable configuration file. If it doesn't find one # the config is empty and uses the defaults defined within a config class # # @param [String] file # Path where config file is stored. The file will be read by the # `config_engine`. # # @param [Object] config_engine (Psych) # The engine to read config file # # @param [true, false] check_unknown_options # Should a warning be put to stdout if there are unknown options in yaml # config file # # @raise [Exceptions::ConfigFileNotReadable] # If an avaiable config file could not be read by the config engine # # @return [AppConfig] # The config instance. If the resulting data structure created by the # config_engine does not respond to `:[]` an empty config object will be # created. attr_reader :file, :logger, :config_engine, :check_unknown_options, :working_directory, :safe def initialize( file: nil, config_engine: Psych, logger: FeduxOrgStdlib::Logging::Logger.new, check_unknown_options: true, working_directory: Dir.getwd, safe: true ) @logger = logger @file = file @config_engine = config_engine @check_unknown_options = check_unknown_options @working_directory = working_directory @safe = safe detect_file unless @file load_config end # Reload from already found config file def reload load_config end # Redected configuration file and reload config afterwards def redetect detect_file load_config end # Show known options for configuration def known_options self.class.known_options end # Lock the configuration def lock _config.freeze end # Output a string presentation of the configuration # # @return [String] # An formatted version of the configuration def to_s # header 'length' = 6 letters length = self.class.known_options.reduce(6) { |a, e| e.size > a ? e.size : a } result = [] result << format("%#{length}s | %s", 'option', 'value') result << format('%s + %s', '-' * length, '-' * 80) self.class.known_options.each do |o| value = public_send(o) value = if value == false Array(value) elsif value.blank? Array('is undefined') elsif value.is_a?(Hash) || value.is_a?(Array) value else Array(value) end result << format("%#{length}s | %s", o, value.to_list) end result.join("\n") end # Return the path to the preferred configuration file # @return [String] # The path to the preferred configuration file def preferred_configuration_file _allowed_config_file_paths.first end # Clear configuration def clear @__config = {} end # Return configuration resetted to defaults def defaults config = self.dup config.clear config end private def detect_file @file = _available_config_file end def load_config unless file logger.debug "No configuration file found at #{_allowed_config_file_paths.to_list}, therefor I'm going to use an empty config object instead." @__config = {} return end begin yaml = if safe config_engine.safe_load(File.read(file), [Symbol, Regexp]) else config_engine.load(File.read(file)) end rescue StandardError => e raise Exceptions::ConfigFileNotReadable, JSON.dump(message: e.message, file: file) end if yaml.blank? @__config = {} elsif yaml.is_a? Hash yaml = yaml.deep_symbolize_keys yaml_withknown_options = yaml.deep_symbolize_keys.slice(*known_options) unknown_options = yaml.keys - yaml_withknown_options.keys logger.warn "Unknown config options #{(unknown_options).to_list} in config file #{file} detected. Please define them in your config class or remove the entries in your config file or disable check via `check_unknown_options: false` to get rid of this warning." unless unknown_options.blank? && check_unknown_options == true @__config = Hash(yaml_withknown_options) else logger.warn "There seems to be a problem transforming config file \"#{file}\" to a hash, therefor I will use an empty config object." @__config = {} end end # The name of the config file # # @return [String] # The name of the config file. It defaults to `.yaml`. If # you want to use a different file name you need to overwrite this # method. def _config_file "#{_config_name}#{_config_file_suffix}" end # The suffix of the config file # # @return [String] # The suffix of the config file def _config_file_suffix '.yaml' end # The base name of the config # # @return [String] # This one returns the base name of the config file (without the file # extension). It uses the class name of the config class # # @example Determine the base name of the config # # class ClientConfig; end # # This will result in `client` as base name for the config file. def _config_name unless (name = _class_name.sub(/Config/, '').underscore.pluralize).blank? return name end fail Exceptions::ClassNameIsMissing, JSON.dump(klass: _class_name) end # The name of your application # # @return [String] # This will strip of the class part of fully qualified class name and # converted it to a path. # # @example Determine application name # # class MyApplication::MyConfig; end # # This will be converted to # # my_application def _application_name _module_name.underscore end # The paths where to look for the config file # # @return [Array] # A list of paths where the config object should look for its config # file. def _allowed_config_file_paths [ ::File.expand_path(::File.join('~', '.config', _application_name, _config_file)), ::File.expand_path(::File.join('~', format('.%s', _application_name), _config_file)), ::File.expand_path(::File.join('~', format('.%s', _config_file))), ::File.expand_path(::File.join('~', format('.%src', _config_name))), ::File.expand_path(::File.join('/etc', _application_name, _config_file)), ::File.expand_path(::File.join(working_directory, _config_file)) ] end def _class_name self.class.name.to_s.demodulize end def _module_name self.class.to_s.deconstantize end def _available_config_file _allowed_config_file_paths.find { |f| ::File.exist? f } end def self._reserved_key_words (methods | instance_methods | private_methods | private_instance_methods) - (Class.methods | Class.private_methods) | [:to_s] end def method_missing(*args, &block) $stderr.puts "Please check if you have defined an option for #{args.first}." super end end end