anyway_config 2.5.4 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anyway
4
+ module AutoCast
5
+ # Regexp to detect array values
6
+ # Array value is a values that contains at least one comma
7
+ # and doesn't start/end with quote or curly braces
8
+ ARRAY_RXP = /\A[^'"{].*\s*,\s*.*[^'"}]\z/
9
+
10
+ class << self
11
+ def call(val)
12
+ return val unless val.is_a?(::Hash) || val.is_a?(::String)
13
+
14
+ case val
15
+ when Hash
16
+ val.transform_values { it = _1;call(it) }
17
+ when ARRAY_RXP
18
+ val.split(/\s*,\s*/).map { it = _1;call(it) }
19
+ when /\A(true|t|yes|y)\z/i
20
+ true
21
+ when /\A(false|f|no|n)\z/i
22
+ false
23
+ when /\A(nil|null)\z/i
24
+ nil
25
+ when /\A\d+\z/
26
+ val.to_i
27
+ when /\A\d*\.\d+\z/
28
+ val.to_f
29
+ when /\A['"].*['"]\z/
30
+ val.gsub(/(\A['"]|['"]\z)/, "")
31
+ else
32
+ val
33
+ end
34
+ end
35
+
36
+ def cast_hash(obj)
37
+ obj.transform_values do |val|
38
+ val.is_a?(::Hash) ? cast_hash(val) : call(val)
39
+ end
40
+ end
41
+
42
+ def coerce(_key, val)
43
+ call(val)
44
+ end
45
+ end
46
+ end
47
+
48
+ module NoCast
49
+ def self.call(val) = val
50
+
51
+ def self.coerce(_key, val) = val
52
+ end
53
+ end
@@ -0,0 +1,473 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anyway/optparse_config"
4
+ require "anyway/dynamic_config"
5
+
6
+ module Anyway # :nodoc:
7
+ using RubyNext
8
+ using Anyway::Ext::DeepDup
9
+ using Anyway::Ext::DeepFreeze
10
+ using Anyway::Ext::Hash
11
+ using Anyway::Ext::FlattenNames
12
+
13
+ using(Module.new do
14
+ refine Object do
15
+ def vm_object_id() = (object_id << 1).to_s(16)
16
+ end
17
+ end)
18
+
19
+ # Base config class
20
+ # Provides `attr_config` method to describe
21
+ # configuration parameters and set defaults
22
+ class Config
23
+ PARAM_NAME = /^[a-z_](\w+)?$/
24
+
25
+ # List of names that couldn't be used as config names
26
+ # (the class instance methods we use)
27
+ RESERVED_NAMES = %i[
28
+ config_name
29
+ env_prefix
30
+ as_env
31
+ values
32
+ class
33
+ clear
34
+ deconstruct_keys
35
+ dig
36
+ dup
37
+ initialize
38
+ load
39
+ load_from_sources
40
+ option_parser
41
+ pretty_print
42
+ raise_validation_error
43
+ reload
44
+ resolve_config_path
45
+ tap
46
+ to_h
47
+ to_source_trace
48
+ write_config_attr
49
+ __type_caster__
50
+ ].freeze
51
+
52
+ class Error < StandardError; end
53
+
54
+ class ValidationError < Error; end
55
+
56
+ include OptparseConfig
57
+ include DynamicConfig
58
+
59
+ class BlockCallback
60
+ attr_reader :block
61
+
62
+ def initialize(block)
63
+ @block = block
64
+ end
65
+
66
+ def apply_to(config)
67
+ config.instance_exec(&block)
68
+ end
69
+ end
70
+
71
+ class NamedCallback
72
+ attr_reader :name
73
+
74
+ def initialize(name)
75
+ @name = name
76
+ end
77
+
78
+ def apply_to(config) = config.send(name)
79
+ end
80
+
81
+ class << self
82
+ def attr_config(*args, **hargs)
83
+ new_defaults = hargs.deep_dup
84
+ new_defaults.stringify_keys!
85
+
86
+ defaults.merge! new_defaults
87
+
88
+ new_keys = ((args + new_defaults.keys) - config_attributes)
89
+
90
+ validate_param_names! new_keys.map(&:to_s)
91
+
92
+ new_keys.map!(&:to_sym)
93
+
94
+ unless (reserved_names = (new_keys & RESERVED_NAMES)).empty?
95
+ raise ArgumentError, "Can not use the following reserved names as config attrubutes: " \
96
+ "#{reserved_names.sort.map(&:to_s).join(", ")}"
97
+ end
98
+
99
+ config_attributes.push(*new_keys)
100
+
101
+ define_config_accessor(*new_keys)
102
+
103
+ # Define predicate methods ("param?") for attributes
104
+ # having `true` or `false` as default values
105
+ new_defaults.each do |key, val|
106
+ next unless val.is_a?(TrueClass) || val.is_a?(FalseClass)
107
+ alias_method :"#{key}?", :"#{key}"
108
+ end
109
+ end
110
+
111
+ def defaults
112
+ return @defaults if instance_variable_defined?(:@defaults)
113
+
114
+ @defaults = if superclass < Anyway::Config
115
+ superclass.defaults.deep_dup
116
+ else
117
+ new_empty_config
118
+ end
119
+ end
120
+
121
+ def config_attributes
122
+ return @config_attributes if instance_variable_defined?(:@config_attributes)
123
+
124
+ @config_attributes = if superclass < Anyway::Config
125
+ superclass.config_attributes.dup
126
+ else
127
+ []
128
+ end
129
+ end
130
+
131
+ def required(*names, env: nil, **nested)
132
+ unknown_names = names + nested.keys - config_attributes
133
+ raise ArgumentError, "Unknown config param: #{unknown_names.join(",")}" if unknown_names.any?
134
+
135
+ return unless Settings.matching_env?(env)
136
+
137
+ required_attributes.push(*names)
138
+ required_attributes.push(*nested.flatten_names)
139
+ end
140
+
141
+ def required_attributes
142
+ return @required_attributes if instance_variable_defined?(:@required_attributes)
143
+
144
+ @required_attributes = if superclass < Anyway::Config
145
+ superclass.required_attributes.dup
146
+ else
147
+ []
148
+ end
149
+ end
150
+
151
+ def on_load(*names, &block)
152
+ raise ArgumentError, "Either methods or block should be specified, not both" if block && !names.empty?
153
+
154
+ if block
155
+ load_callbacks << BlockCallback.new(block)
156
+ else
157
+ load_callbacks.push(*names.map { it = _1;NamedCallback.new(it) })
158
+ end
159
+ end
160
+
161
+ def load_callbacks
162
+ return @load_callbacks if instance_variable_defined?(:@load_callbacks)
163
+
164
+ @load_callbacks = if superclass <= Anyway::Config
165
+ superclass.load_callbacks.dup
166
+ else
167
+ []
168
+ end
169
+ end
170
+
171
+ def config_name(val = nil)
172
+ return (@explicit_config_name = val.to_s) unless val.nil?
173
+
174
+ return @config_name if instance_variable_defined?(:@config_name)
175
+
176
+ @config_name = explicit_config_name || build_config_name
177
+ end
178
+
179
+ def explicit_config_name
180
+ return @explicit_config_name if instance_variable_defined?(:@explicit_config_name)
181
+
182
+ @explicit_config_name =
183
+ if superclass.respond_to?(:explicit_config_name)
184
+ superclass.explicit_config_name
185
+ end
186
+ end
187
+
188
+ def explicit_config_name?() = !explicit_config_name.nil?
189
+
190
+ def env_prefix(val = nil)
191
+ return (@env_prefix = val.to_s.upcase) unless val.nil?
192
+
193
+ return @env_prefix if instance_variable_defined?(:@env_prefix)
194
+
195
+ @env_prefix = if superclass < Anyway::Config && superclass.explicit_config_name?
196
+ superclass.env_prefix
197
+ else
198
+ config_name.upcase
199
+ end
200
+ end
201
+
202
+ def loader_options(val = nil)
203
+ return (@loader_options = val) unless val.nil?
204
+
205
+ return @loader_options if instance_variable_defined?(:@loader_options)
206
+
207
+ @loader_options = if superclass < Anyway::Config
208
+ superclass.loader_options
209
+ else
210
+ {}
211
+ end
212
+ end
213
+
214
+ def new_empty_config() = {}
215
+
216
+ def coerce_types(mapping)
217
+ Utils.deep_merge!(coercion_mapping, mapping)
218
+
219
+ mapping.each do |key, val|
220
+ type = val.is_a?(::Hash) ? val[:type] : val
221
+ next if type != :boolean
222
+
223
+ alias_method :"#{key}?", :"#{key}"
224
+ end
225
+ end
226
+
227
+ def coercion_mapping
228
+ return @coercion_mapping if instance_variable_defined?(:@coercion_mapping)
229
+
230
+ @coercion_mapping = if superclass < Anyway::Config
231
+ superclass.coercion_mapping.deep_dup
232
+ else
233
+ {}
234
+ end
235
+ end
236
+
237
+ def type_caster(val = nil)
238
+ return @type_caster unless val.nil?
239
+
240
+ @type_caster ||=
241
+ if coercion_mapping.empty?
242
+ fallback_type_caster
243
+ else
244
+ ::Anyway::TypeCaster.new(coercion_mapping, fallback: fallback_type_caster)
245
+ end
246
+ end
247
+
248
+ def fallback_type_caster(val = nil)
249
+ return (@fallback_type_caster = val) unless val.nil?
250
+
251
+ return @fallback_type_caster if instance_variable_defined?(:@fallback_type_caster)
252
+
253
+ @fallback_type_caster = if superclass < Anyway::Config
254
+ superclass.fallback_type_caster.deep_dup
255
+ else
256
+ ::Anyway::AutoCast
257
+ end
258
+ end
259
+
260
+ def disable_auto_cast!
261
+ @fallback_type_caster = ::Anyway::NoCast
262
+ end
263
+
264
+ private
265
+
266
+ def define_config_accessor(*names)
267
+ names.each do |name|
268
+ accessors_module.module_eval <<~RUBY, __FILE__, __LINE__ + 1
269
+ def #{name}=(val)
270
+ __trace__&.record_value(val, "#{name}", **Tracing.current_trace_source)
271
+ values[:#{name}] = val
272
+ end
273
+
274
+ def #{name}
275
+ values[:#{name}]
276
+ end
277
+ RUBY
278
+ end
279
+ end
280
+
281
+ def accessors_module
282
+ return @accessors_module if instance_variable_defined?(:@accessors_module)
283
+
284
+ @accessors_module = Module.new.tap do |mod|
285
+ include mod
286
+ end
287
+ end
288
+
289
+ def build_config_name
290
+ unless name
291
+ raise "Please, specify config name explicitly for anonymous class " \
292
+ "via `config_name :my_config`"
293
+ end
294
+
295
+ # handle two cases:
296
+ # - SomeModule::Config => "some_module"
297
+ # - SomeConfig => "some"
298
+ unless name =~ /^(\w+)(::)?Config$/
299
+ raise "Couldn't infer config name, please, specify it explicitly " \
300
+ "via `config_name :my_config`"
301
+ end
302
+
303
+ # TODO(v3.0): Replace downcase with underscore
304
+ Regexp.last_match[1].tap(&:downcase!)
305
+ end
306
+
307
+ def validate_param_names!(names)
308
+ invalid_names = names.reject { |name| name =~ PARAM_NAME }
309
+ return if invalid_names.empty?
310
+
311
+ raise ArgumentError, "Invalid attr_config name: #{invalid_names.join(", ")}.\n" \
312
+ "Valid names must satisfy /#{PARAM_NAME.source}/."
313
+ end
314
+ end
315
+
316
+ on_load :validate_required_attributes!
317
+
318
+ attr_reader :config_name, :env_prefix
319
+
320
+ # Instantiate config instance.
321
+ #
322
+ # Example:
323
+ #
324
+ # my_config = Anyway::Config.new()
325
+ #
326
+ # # provide some values explicitly
327
+ # my_config = Anyway::Config.new({some: :value})
328
+ #
329
+ def initialize(overrides = nil)
330
+ @config_name = self.class.config_name
331
+
332
+ raise ArgumentError, "Config name is missing" unless @config_name
333
+
334
+ @env_prefix = self.class.env_prefix
335
+ @values = {}
336
+
337
+ load(overrides)
338
+ end
339
+
340
+ def reload(overrides = nil)
341
+ clear
342
+ load(overrides)
343
+ self
344
+ end
345
+
346
+ def clear
347
+ values.clear
348
+ @__trace__ = nil
349
+ self
350
+ end
351
+
352
+ def load(overrides = nil)
353
+ base_config = self.class.defaults.deep_dup
354
+
355
+ trace = Tracing.capture do
356
+ Tracing.trace!(:defaults) { base_config }
357
+
358
+ config_path = resolve_config_path(config_name, env_prefix)
359
+
360
+ load_from_sources(
361
+ base_config,
362
+ name: config_name,
363
+ env_prefix:,
364
+ config_path:,
365
+ **self.class.loader_options
366
+ )
367
+
368
+ if overrides
369
+ Tracing.trace!(:load) { overrides }
370
+
371
+ Utils.deep_merge!(base_config, overrides)
372
+ end
373
+ end
374
+
375
+ base_config.each do |key, val|
376
+ write_config_attr(key.to_sym, val)
377
+ end
378
+
379
+ # Trace may contain unknown attributes
380
+ trace&.keep_if { |key| self.class.config_attributes.include?(key.to_sym) }
381
+
382
+ # Run on_load callbacks
383
+ self.class.load_callbacks.each { it = _1;it.apply_to(self) }
384
+
385
+ # Set trace after we write all the values to
386
+ # avoid changing the source to accessor
387
+ @__trace__ = trace
388
+
389
+ self
390
+ end
391
+
392
+ def load_from_sources(base_config, **)
393
+ Anyway.loaders.each do |(_id, loader)|
394
+ Utils.deep_merge!(base_config, loader.call(**))
395
+ end
396
+ base_config
397
+ end
398
+
399
+ def dig(*) = values.dig(*)
400
+
401
+ def to_h() = values.deep_dup.deep_freeze
402
+
403
+ def dup
404
+ self.class.allocate.tap do |new_config|
405
+ %i[config_name env_prefix __trace__].each do |ivar|
406
+ new_config.instance_variable_set(:"@#{ivar}", send(ivar).dup)
407
+ end
408
+ new_config.instance_variable_set(:@values, values.deep_dup)
409
+ end
410
+ end
411
+
412
+ def resolve_config_path(name, env_prefix)
413
+ Anyway.env.fetch(env_prefix).delete("conf") || Settings.default_config_path.call(name)
414
+ end
415
+
416
+ def deconstruct_keys(keys) = values.deconstruct_keys(keys)
417
+
418
+ def to_source_trace() = __trace__&.to_h
419
+
420
+ def inspect
421
+ "#<#{self.class}:0x#{vm_object_id.rjust(16, "0")} config_name=\"#{config_name}\" env_prefix=\"#{env_prefix}\" " \
422
+ "values=#{values.inspect}>"
423
+ end
424
+
425
+ def pretty_print(q)
426
+ q.object_group self do
427
+ q.nest(1) do
428
+ q.breakable
429
+ q.text "config_name=#{config_name.inspect}"
430
+ q.breakable
431
+ q.text "env_prefix=#{env_prefix.inspect}"
432
+ q.breakable
433
+ q.text "values:"
434
+ q.pp __trace__
435
+ end
436
+ end
437
+ end
438
+
439
+ def as_env
440
+ Env.from_hash(to_h, prefix: env_prefix)
441
+ end
442
+
443
+ private
444
+
445
+ attr_reader :values, :__trace__
446
+
447
+ def validate_required_attributes!
448
+ self.class.required_attributes.select do |name|
449
+ val = values.dig(*name.to_s.split(".").map(&:to_sym))
450
+ val.nil? || (val.is_a?(String) && val.empty?)
451
+ end.then do |missing|
452
+ next if missing.empty?
453
+ raise_validation_error "The following config parameters for `#{self.class.name}(config_name: #{self.class.config_name})` are missing or empty: #{missing.join(", ")}"
454
+ end
455
+ end
456
+
457
+ def write_config_attr(key, val)
458
+ key = key.to_sym
459
+ return unless self.class.config_attributes.include?(key)
460
+
461
+ val = __type_caster__.coerce(key, val)
462
+ public_send(:"#{key}=", val)
463
+ end
464
+
465
+ def raise_validation_error(msg)
466
+ raise ValidationError, msg
467
+ end
468
+
469
+ def __type_caster__
470
+ self.class.type_caster
471
+ end
472
+ end
473
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anyway
4
+ # Provides method to trace values association
5
+ module Tracing
6
+ using Anyway::Ext::DeepDup
7
+
8
+ using(Module.new do
9
+ refine Thread::Backtrace::Location do
10
+ def path_lineno() = "#{path}:#{lineno}"
11
+ end
12
+ end)
13
+
14
+ class Trace
15
+ UNDEF = Object.new
16
+
17
+ attr_reader :type, :value, :source
18
+
19
+ def initialize(type = :trace, value = UNDEF, **source)
20
+ @type = type
21
+ @source = source
22
+ @value = (value == UNDEF) ? Hash.new { |h, k| h[k] = Trace.new(:trace) } : value
23
+ end
24
+
25
+ def dig(...)
26
+ value.dig(...)
27
+ end
28
+
29
+ def record_value(val, *path, **)
30
+ key = path.pop
31
+ trace = if val.is_a?(Hash)
32
+ Trace.new.tap { it = _1;it.merge_values(val, **) }
33
+ else
34
+ Trace.new(:value, val, **)
35
+ end
36
+
37
+ target_trace = path.empty? ? self : value.dig(*path)
38
+ target_trace.record_key(key.to_s, trace)
39
+
40
+ val
41
+ end
42
+
43
+ def merge_values(hash, **)
44
+ return hash unless hash
45
+
46
+ hash.each do |key, val|
47
+ if val.is_a?(Hash)
48
+ value[key.to_s].merge_values(val, **)
49
+ else
50
+ value[key.to_s] = Trace.new(:value, val, **)
51
+ end
52
+ end
53
+
54
+ hash
55
+ end
56
+
57
+ def record_key(key, key_trace)
58
+ @value = Hash.new { |h, k| h[k] = Trace.new(:trace) } unless value.is_a?(::Hash)
59
+
60
+ value[key] = key_trace
61
+ end
62
+
63
+ def merge!(another_trace)
64
+ raise ArgumentError, "You can only merge into a :trace type, and this is :#{type}" unless trace?
65
+ raise ArgumentError, "You can only merge a :trace type, but trying :#{type}" unless another_trace.trace?
66
+
67
+ another_trace.value.each do |key, sub_trace|
68
+ if sub_trace.trace?
69
+ value[key].merge! sub_trace
70
+ else
71
+ value[key] = sub_trace
72
+ end
73
+ end
74
+ end
75
+
76
+ def keep_if(...)
77
+ raise ArgumentError, "You can only filter :trace type, and this is :#{type}" unless trace?
78
+ value.keep_if(...)
79
+ end
80
+
81
+ def clear() = value.clear
82
+
83
+ def trace?() = type == :trace
84
+
85
+ def to_h
86
+ if trace?
87
+ value.transform_values(&:to_h).tap { it = _1;it.default_proc = nil }
88
+ else
89
+ {value:, source:}
90
+ end
91
+ end
92
+
93
+ def dup() = self.class.new(type, value.dup, **source)
94
+
95
+ def pretty_print(q)
96
+ if trace?
97
+ q.nest(2) do
98
+ q.breakable ""
99
+ q.seplist(value, nil, :each) do |k, v|
100
+ q.group do
101
+ q.text k
102
+ q.text " =>"
103
+ if v.trace?
104
+ q.text " { "
105
+ q.pp v
106
+ q.breakable " "
107
+ q.text "}"
108
+ else
109
+ q.breakable " "
110
+ q.pp v
111
+ end
112
+ end
113
+ end
114
+ end
115
+ else
116
+ q.pp value
117
+ q.group(0, " (", ")") do
118
+ q.seplist(source, lambda { q.breakable " " }, :each) do |k, v|
119
+ q.group do
120
+ q.text k.to_s
121
+ q.text "="
122
+ q.text v.to_s
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ class << self
131
+ def capture
132
+ unless Settings.tracing_enabled
133
+ yield
134
+ return
135
+ end
136
+
137
+ trace = Trace.new
138
+ trace_stack.push trace
139
+ yield
140
+ trace_stack.last
141
+ ensure
142
+ trace_stack.pop
143
+ end
144
+
145
+ def trace_stack
146
+ (Thread.current[:__anyway__trace_stack__] ||= [])
147
+ end
148
+
149
+ def current_trace() = trace_stack.last
150
+
151
+ alias_method :tracing?, :current_trace
152
+
153
+ def source_stack
154
+ (Thread.current[:__anyway__trace_source_stack__] ||= [])
155
+ end
156
+
157
+ def current_trace_source
158
+ source_stack.last || accessor_source(caller_locations(2, 1).first)
159
+ end
160
+
161
+ def with_trace_source(src)
162
+ source_stack << src
163
+ yield
164
+ ensure
165
+ source_stack.pop
166
+ end
167
+
168
+ private
169
+
170
+ def accessor_source(location)
171
+ {type: :accessor, called_from: location.path_lineno}
172
+ end
173
+ end
174
+
175
+ module_function
176
+
177
+ def trace!(type, *path, **)
178
+ return yield unless Tracing.tracing?
179
+ val = yield
180
+ if val.is_a?(Hash)
181
+ Tracing.current_trace.merge_values(val, type:, **)
182
+ elsif !path.empty?
183
+ Tracing.current_trace.record_value(val, *path, type:, **)
184
+ end
185
+ val
186
+ end
187
+ end
188
+ end