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