lib/anyway/config.rb in anyway_config-2.0.0.pre2 vs lib/anyway/config.rb in anyway_config-2.0.0.rc1

- old
+ new

@@ -1,48 +1,121 @@ # frozen_string_literal: true require "anyway/optparse_config" require "anyway/dynamic_config" -require "anyway/ext/deep_dup" -require "anyway/ext/deep_freeze" -require "anyway/ext/hash" -require "anyway/ext/string_serialize" - module Anyway # :nodoc: + using RubyNext using Anyway::Ext::DeepDup using Anyway::Ext::DeepFreeze using Anyway::Ext::Hash - using Anyway::Ext::StringSerialize + using(Module.new do + refine Object do + def vm_object_id + (object_id << 1).to_s(16) + end + end + end) + # Base config class # Provides `attr_config` method to describe # configuration parameters and set defaults class Config + PARAM_NAME = /^[a-z_]([\w]+)?$/ + + # List of names that couldn't be used as config names + # (the class instance methods we use) + RESERVED_NAMES = %i[ + config_name + env_prefix + values + class + clear + deconstruct_keys + dig + initialize + load + load_from_sources + option_parser + pretty_print + raise_validation_error + reload + resolve_config_path + to_h + to_source_trace + write_config_attr + ].freeze + + class Error < StandardError; end + class ValidationError < Error; end + include OptparseConfig include DynamicConfig + class BlockCallback + attr_reader :block + + def initialize(block) + @block = block + end + + def apply_to(config) + config.instance_exec(&block) + end + end + + class NamedCallback + attr_reader :name + + def initialize(name) + @name = name + end + + def apply_to(config) + config.send(name) + end + end + class << self def attr_config(*args, **hargs) new_defaults = hargs.deep_dup new_defaults.stringify_keys! defaults.merge! new_defaults - new_keys = (args + new_defaults.keys) - config_attributes + new_keys = ((args + new_defaults.keys) - config_attributes) + + validate_param_names! new_keys.map(&:to_s) + + new_keys.map!(&:to_sym) + + unless (reserved_names = (new_keys & RESERVED_NAMES)).empty? + raise ArgumentError, "Can not use the following reserved names as config attrubutes: " \ + "#{reserved_names.sort.map(&:to_s).join(", ")}" + end + config_attributes.push(*new_keys) - attr_accessor(*new_keys) + + define_config_accessor(*new_keys) + + # Define predicate methods ("param?") for attributes + # having `true` or `false` as default values + new_defaults.each do |key, val| + next unless val.is_a?(TrueClass) || val.is_a?(FalseClass) + alias_method :"#{key}?", :"#{key}" + end end def defaults return @defaults if instance_variable_defined?(:@defaults) @defaults = if superclass < Anyway::Config superclass.defaults.deep_dup else - {} + new_empty_config end end def config_attributes return @config_attributes if instance_variable_defined?(:@config_attributes) @@ -53,10 +126,50 @@ else [] end end + def required(*names) + unless (unknown_names = (names - config_attributes)).empty? + raise ArgumentError, "Unknown config param: #{unknown_names.join(",")}" + end + + required_attributes.push(*names) + end + + def required_attributes + return @required_attributes if instance_variable_defined?(:@required_attributes) + + @required_attributes = + if superclass < Anyway::Config + superclass.required_attributes.dup + else + [] + end + end + + def on_load(*names, &block) + raise ArgumentError, "Either methods or block should be specified, not both" if block_given? && !names.empty? + + if block_given? + load_callbacks << BlockCallback.new(block) + else + load_callbacks.push(*names.map { NamedCallback.new(_1) }) + end + end + + def load_callbacks + return @load_callbacks if instance_variable_defined?(:@load_callbacks) + + @load_callbacks = + if superclass <= Anyway::Config + superclass.load_callbacks.dup + else + [] + end + end + def config_name(val = nil) return (@explicit_config_name = val.to_s) unless val.nil? return @config_name if instance_variable_defined?(:@config_name) @@ -87,12 +200,40 @@ else config_name.upcase end end + def new_empty_config + {} + end + private + def define_config_accessor(*names) + names.each do |name| + accessors_module.module_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{name}=(val) + __trace__&.record_value(val, \"#{name}\", Tracing.current_trace_source) + # DEPRECATED: instance variable set will be removed in 2.1 + @#{name} = values[:#{name}] = val + end + + def #{name} + values[:#{name}] + end + RUBY + end + end + + def accessors_module + return @accessors_module if instance_variable_defined?(:@accessors_module) + + @accessors_module = Module.new.tap do |mod| + include mod + end + end + def build_config_name unless name raise "Please, specify config name explicitly for anonymous class " \ "via `config_name :my_config`" end @@ -105,12 +246,22 @@ "via `config_name :my_config`" end Regexp.last_match[1].tap(&:downcase!) end + + def validate_param_names!(names) + invalid_names = names.reject { |name| name =~ PARAM_NAME } + return if invalid_names.empty? + + raise ArgumentError, "Invalid attr_config name: #{invalid_names.join(", ")}.\n" \ + "Valid names must satisfy /#{PARAM_NAME.source}/." + end end + on_load :validate_required_attributes! + attr_reader :config_name, :env_prefix # Instantiate config instance. # # Example: @@ -118,110 +269,136 @@ # my_config = Anyway::Config.new() # # # provide some values explicitly # my_config = Anyway::Config.new({some: :value}) # - def initialize(overrides = {}) + def initialize(overrides = nil) @config_name = self.class.config_name raise ArgumentError, "Config name is missing" unless @config_name @env_prefix = self.class.env_prefix + @values = {} load(overrides) end - def reload(overrides = {}) + def reload(overrides = nil) clear load(overrides) self end def clear - self.class.config_attributes.each do |attr| - send("#{attr}=", nil) - end + values.clear + @__trace__ = nil self end - def load(overrides = {}) - base_config = (self.class.defaults || {}).deep_dup + def load(overrides = nil) + base_config = self.class.defaults.deep_dup - base_config.deep_merge!( + trace = Tracing.capture do + Tracing.trace!(:defaults) { base_config } + load_from_sources( + base_config, name: config_name, env_prefix: env_prefix, config_path: resolve_config_path(config_name, env_prefix) ) - ) - base_config.merge!(overrides) unless overrides.nil? + if overrides + Tracing.trace!(:load) { overrides } + base_config.deep_merge!(overrides) + end + end + base_config.each do |key, val| - set_value(key, val) + write_config_attr(key.to_sym, val) end + + # Trace may contain unknown attributes + trace&.keep_if { |key| self.class.config_attributes.include?(key.to_sym) } + + # Run on_load callbacks + self.class.load_callbacks.each { _1.apply_to(self) } + + # Set trace after we write all the values to + # avoid changing the source to accessor + @__trace__ = trace + + self end - def load_from_sources(**options) - base_config = {} - each_source(options) do |config| - base_config.deep_merge!(config) if config + def load_from_sources(base_config, **options) + Anyway.loaders.each do |(_id, loader)| + base_config.deep_merge!(loader.call(**options)) end base_config end - def each_source(options) - yield load_from_file(options) - yield load_from_env(options) + def dig(*keys) + values.dig(*keys) end - def load_from_file(name:, env_prefix:, config_path:, **_options) - file_config = load_from_yml(config_path) + def to_h + values.deep_dup.deep_freeze + end - if Anyway::Settings.use_local_files - local_config_path = config_path.sub(/\.yml/, ".local.yml") - file_config.deep_merge!(load_from_yml(local_config_path)) - end + def resolve_config_path(name, env_prefix) + Anyway.env.fetch(env_prefix).delete("conf") || Settings.default_config_path.call(name) + end - file_config + def deconstruct_keys(keys) + values.deconstruct_keys(keys) end - def load_from_env(name:, env_prefix:, **_options) - Anyway.env.fetch(env_prefix) + def to_source_trace + __trace__&.to_h end - def to_h - self.class.config_attributes.each_with_object({}) do |key, obj| - obj[key.to_sym] = send(key) - end.deep_dup.deep_freeze + def inspect + "#<#{self.class}:0x#{vm_object_id.rjust(16, "0")} config_name=\"#{config_name}\" env_prefix=\"#{env_prefix}\" " \ + "values=#{values.inspect}>" end - def resolve_config_path(name, env_prefix) - Anyway.env.fetch(env_prefix).delete("conf") || default_config_path(name) + def pretty_print(q) + q.object_group self do + q.nest(1) do + q.breakable + q.text "config_name=#{config_name.inspect}" + q.breakable + q.text "env_prefix=#{env_prefix.inspect}" + q.breakable + q.text "values:" + q.pp __trace__ + end + end end private - def set_value(key, val) - send("#{key}=", val) if respond_to?(key) + attr_reader :values, :__trace__ + + def validate_required_attributes! + self.class.required_attributes.select do |name| + values[name].nil? || (values[name].is_a?(String) && values[name].empty?) + end.then do |missing| + next if missing.empty? + raise_validation_error "The following config parameters are missing or empty: #{missing.join(", ")}" + end end - def load_from_yml(path) - return {} unless File.file?(path) + def write_config_attr(key, val) + key = key.to_sym + return unless self.class.config_attributes.include?(key) - parse_yml(path) + public_send(:"#{key}=", val) end - def default_config_path(name) - "./config/#{name}.yml" - end - - def parse_yml(path) - require "yaml" - if defined?(ERB) - YAML.safe_load(ERB.new(File.read(path)).result, [], [], true) - else - YAML.load_file(path) - end + def raise_validation_error(msg) + raise ValidationError, msg end end end