anyway_config 2.0.6 → 2.1.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.
data/README.md CHANGED
@@ -329,7 +329,9 @@ and then use [Rails generators](#generators) to make your application Anyway Con
329
329
 
330
330
  Your config is filled up with values from the following sources (ordered by priority from low to high):
331
331
 
332
- - `RAILS_ROOT/config/my_cool_gem.yml` (for the current `RAILS_ENV`, supports `ERB`):
332
+ 1) **YAML configuration files**: `RAILS_ROOT/config/my_cool_gem.yml`.
333
+
334
+ Recognizes Rails environment, supports `ERB`:
333
335
 
334
336
  ```yml
335
337
  test:
@@ -341,9 +343,51 @@ development:
341
343
  port: 3000
342
344
  ```
343
345
 
344
- **NOTE:** you can override the default YML lookup path by setting `MYCOOLGEM_CONF` env variable.
346
+ ### Multi-env configuration
347
+
348
+ _⚡️ This feature will be turned on by default in the future releases. You can turn it on now via `config.anyway_config.future.use :unwrap_known_environments`._
349
+
350
+ If the YML does not have keys that are one of the "known" Rails environments (development, production, test)—the same configuration will be available in all environments, similar to non-Rails behavior:
351
+
352
+ ```yml
353
+ host: localhost
354
+ port: 3002
355
+ # These values will be active in all environments
356
+ ```
357
+
358
+ To extend the list of known environments, use the setting in the relevant part of your Rails code:
359
+
360
+ ```ruby
361
+ Rails.application.config.anyway_config.known_environments << "staging"
362
+ ```
363
+
364
+ If your YML defines at least a single "environmental" top-level, you _have_ to separate all your settings per-environment. You can't mix and match:
365
+
366
+ ```yml
367
+ staging:
368
+ host: localhost # This value will be loaded when Rails.env.staging? is true
369
+
370
+ port: 3002 # This value will not be loaded at all
371
+ ```
372
+
373
+ You can specify the lookup path for YAML files in one of the following ways:
374
+
375
+ - By setting `config.anyway_config.default_config_path` to a target directory path:
376
+
377
+ ```ruby
378
+ config.anyway_config.default_config_path = "/etc/configs"
379
+ config.anyway_config.default_config_path = Rails.root.join("etc", "configs")
380
+ ```
381
+
382
+ - By setting `config.anyway_config.default_config_path` to a Proc, which accepts a config name and returns the path:
345
383
 
346
- - `Rails.application.secrets.my_cool_gem` (if `secrets.yml` present):
384
+ ```ruby
385
+ config.anyway_config.default_config_path = ->(name) { Rails.root.join("data", "configs", "#{name}.yml") }
386
+ ```
387
+
388
+ - By overriding a specific config YML file path via the `<NAME>_CONF` env variable, e.g., `MYCOOLGEM_CONF=path/to/cool.yml`
389
+
390
+ 2) **Rails secrets**: `Rails.application.secrets.my_cool_gem` (if `secrets.yml` present).
347
391
 
348
392
  ```yml
349
393
  # config/secrets.yml
@@ -352,7 +396,7 @@ development:
352
396
  port: 4444
353
397
  ```
354
398
 
355
- - `Rails.application.credentials.my_cool_gem` (if supported):
399
+ 3) **Rails credentials**: `Rails.application.credentials.my_cool_gem` (if supported):
356
400
 
357
401
  ```yml
358
402
  my_cool_gem:
@@ -361,7 +405,7 @@ my_cool_gem:
361
405
 
362
406
  **NOTE:** You can backport Rails 6 per-environment credentials to Rails 5.2 app using [this patch](https://gist.github.com/palkan/e27e4885535ff25753aefce45378e0cb).
363
407
 
364
- - `ENV['MYCOOLGEM_*']`.
408
+ 4) **Environment variables**: `ENV['MYCOOLGEM_*']`.
365
409
 
366
410
  See [environment variables](#environment-variables).
367
411
 
@@ -444,7 +488,7 @@ Alternatively, you can call `rails g anyway:app_config name param1 param2 ...`.
444
488
 
445
489
  The default data loading mechanism for non-Rails applications is the following (ordered by priority from low to high):
446
490
 
447
- - `./config/<config-name>.yml` (`ERB` is supported if `erb` is loaded)
491
+ 1) **YAML configuration files**: `./config/<config-name>.yml`.
448
492
 
449
493
  In pure Ruby apps, we do not know about _environments_ (`test`, `development`, `production`, etc.); thus, we assume that the YAML contains values for a single environment:
450
494
 
@@ -453,9 +497,25 @@ host: localhost
453
497
  port: 3000
454
498
  ```
455
499
 
456
- **NOTE:** you can override the default YML lookup path by setting `MYCOOLGEM_CONF` env variable.
500
+ `ERB` is supported if `erb` is loaded (thus, you need to call `require "erb"` somewhere before loading configuration).
501
+
502
+ You can specify the lookup path for YAML files in one of the following ways:
503
+
504
+ - By setting `Anyway::Settings.default_config_path` to a target directory path:
505
+
506
+ ```ruby
507
+ Anyway::Settings.default_config_path = "/etc/configs"
508
+ ```
509
+
510
+ - By setting `Anyway::Settings.default_config_path` to a Proc, which accepts a config name and returns the path:
511
+
512
+ ```ruby
513
+ Anyway::Settings.default_config_path = ->(name) { Rails.root.join("data", "configs", "#{name}.yml") }
514
+ ```
515
+
516
+ - By overriding a specific config YML file path via the `<NAME>_CONF` env variable, e.g., `MYCOOLGEM_CONF=path/to/cool.yml`
457
517
 
458
- - `ENV['MYCOOLGEM_*']`.
518
+ 2) **Environment variables**: `ENV['MYCOOLGEM_*']`.
459
519
 
460
520
  See [environment variables](#environment-variables).
461
521
 
@@ -666,7 +726,7 @@ If you want to delete the env var, pass `nil` as the value.
666
726
 
667
727
  This helper is automatically included to RSpec if `RAILS_ENV` or `RACK_ENV` env variable is equal to "test". It's only available for the example with the tag `type: :config` or with the path `spec/configs/...`.
668
728
 
669
- You can add it manually by requiring `"anyway/testing/helpers"` and including the `Anyway::Test::Helpers` module (into RSpec configuration or Minitest test class).
729
+ You can add it manually by requiring `"anyway/testing/helpers"` and including the `Anyway::Testing::Helpers` module (into RSpec configuration or Minitest test class).
670
730
 
671
731
  ## OptionParser integration
672
732
 
@@ -0,0 +1,391 @@
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
+
12
+ using(Module.new do
13
+ refine Object do
14
+ def vm_object_id() = (object_id << 1).to_s(16)
15
+ end
16
+ end)
17
+
18
+ # Base config class
19
+ # Provides `attr_config` method to describe
20
+ # configuration parameters and set defaults
21
+ class Config
22
+ PARAM_NAME = /^[a-z_](\w+)?$/
23
+
24
+ # List of names that couldn't be used as config names
25
+ # (the class instance methods we use)
26
+ RESERVED_NAMES = %i[
27
+ config_name
28
+ env_prefix
29
+ values
30
+ class
31
+ clear
32
+ deconstruct_keys
33
+ dig
34
+ dup
35
+ initialize
36
+ load
37
+ load_from_sources
38
+ option_parser
39
+ pretty_print
40
+ raise_validation_error
41
+ reload
42
+ resolve_config_path
43
+ tap
44
+ to_h
45
+ to_source_trace
46
+ write_config_attr
47
+ ].freeze
48
+
49
+ class Error < StandardError; end
50
+
51
+ class ValidationError < Error; end
52
+
53
+ include OptparseConfig
54
+ include DynamicConfig
55
+
56
+ class BlockCallback
57
+ attr_reader :block
58
+
59
+ def initialize(block)
60
+ @block = block
61
+ end
62
+
63
+ def apply_to(config)
64
+ config.instance_exec(&block)
65
+ end
66
+ end
67
+
68
+ class NamedCallback
69
+ attr_reader :name
70
+
71
+ def initialize(name)
72
+ @name = name
73
+ end
74
+
75
+ def apply_to(config) = config.send(name)
76
+ end
77
+
78
+ class << self
79
+ def attr_config(*args, **hargs)
80
+ new_defaults = hargs.deep_dup
81
+ new_defaults.stringify_keys!
82
+
83
+ defaults.merge! new_defaults
84
+
85
+ new_keys = ((args + new_defaults.keys) - config_attributes)
86
+
87
+ validate_param_names! new_keys.map(&:to_s)
88
+
89
+ new_keys.map!(&:to_sym)
90
+
91
+ unless (reserved_names = (new_keys & RESERVED_NAMES)).empty?
92
+ raise ArgumentError, "Can not use the following reserved names as config attrubutes: " \
93
+ "#{reserved_names.sort.map(&:to_s).join(", ")}"
94
+ end
95
+
96
+ config_attributes.push(*new_keys)
97
+
98
+ define_config_accessor(*new_keys)
99
+
100
+ # Define predicate methods ("param?") for attributes
101
+ # having `true` or `false` as default values
102
+ new_defaults.each do |key, val|
103
+ next unless val.is_a?(TrueClass) || val.is_a?(FalseClass)
104
+ alias_method :"#{key}?", :"#{key}"
105
+ end
106
+ end
107
+
108
+ def defaults
109
+ return @defaults if instance_variable_defined?(:@defaults)
110
+
111
+ @defaults = if superclass < Anyway::Config
112
+ superclass.defaults.deep_dup
113
+ else
114
+ new_empty_config
115
+ end
116
+ end
117
+
118
+ def config_attributes
119
+ return @config_attributes if instance_variable_defined?(:@config_attributes)
120
+
121
+ @config_attributes = if superclass < Anyway::Config
122
+ superclass.config_attributes.dup
123
+ else
124
+ []
125
+ end
126
+ end
127
+
128
+ def required(*names)
129
+ unless (unknown_names = (names - config_attributes)).empty?
130
+ raise ArgumentError, "Unknown config param: #{unknown_names.join(",")}"
131
+ end
132
+
133
+ required_attributes.push(*names)
134
+ end
135
+
136
+ def required_attributes
137
+ return @required_attributes if instance_variable_defined?(:@required_attributes)
138
+
139
+ @required_attributes = if superclass < Anyway::Config
140
+ superclass.required_attributes.dup
141
+ else
142
+ []
143
+ end
144
+ end
145
+
146
+ def on_load(*names, &block)
147
+ raise ArgumentError, "Either methods or block should be specified, not both" if block && !names.empty?
148
+
149
+ if block
150
+ load_callbacks << BlockCallback.new(block)
151
+ else
152
+ load_callbacks.push(*names.map { NamedCallback.new(_1) })
153
+ end
154
+ end
155
+
156
+ def load_callbacks
157
+ return @load_callbacks if instance_variable_defined?(:@load_callbacks)
158
+
159
+ @load_callbacks = if superclass <= Anyway::Config
160
+ superclass.load_callbacks.dup
161
+ else
162
+ []
163
+ end
164
+ end
165
+
166
+ def config_name(val = nil)
167
+ return (@explicit_config_name = val.to_s) unless val.nil?
168
+
169
+ return @config_name if instance_variable_defined?(:@config_name)
170
+
171
+ @config_name = explicit_config_name || build_config_name
172
+ end
173
+
174
+ def explicit_config_name
175
+ return @explicit_config_name if instance_variable_defined?(:@explicit_config_name)
176
+
177
+ @explicit_config_name =
178
+ if superclass.respond_to?(:explicit_config_name)
179
+ superclass.explicit_config_name
180
+ end
181
+ end
182
+
183
+ def explicit_config_name?() = !explicit_config_name.nil?
184
+
185
+ def env_prefix(val = nil)
186
+ return (@env_prefix = val.to_s.upcase) unless val.nil?
187
+
188
+ return @env_prefix if instance_variable_defined?(:@env_prefix)
189
+
190
+ @env_prefix = if superclass < Anyway::Config && superclass.explicit_config_name?
191
+ superclass.env_prefix
192
+ else
193
+ config_name.upcase
194
+ end
195
+ end
196
+
197
+ def new_empty_config() = {}
198
+
199
+ private
200
+
201
+ def define_config_accessor(*names)
202
+ names.each do |name|
203
+ accessors_module.module_eval <<~RUBY, __FILE__, __LINE__ + 1
204
+ def #{name}=(val)
205
+ __trace__&.record_value(val, \"#{name}\", **Tracing.current_trace_source)
206
+ values[:#{name}] = val
207
+ end
208
+
209
+ def #{name}
210
+ values[:#{name}]
211
+ end
212
+ RUBY
213
+ end
214
+ end
215
+
216
+ def accessors_module
217
+ return @accessors_module if instance_variable_defined?(:@accessors_module)
218
+
219
+ @accessors_module = Module.new.tap do |mod|
220
+ include mod
221
+ end
222
+ end
223
+
224
+ def build_config_name
225
+ unless name
226
+ raise "Please, specify config name explicitly for anonymous class " \
227
+ "via `config_name :my_config`"
228
+ end
229
+
230
+ # handle two cases:
231
+ # - SomeModule::Config => "some_module"
232
+ # - SomeConfig => "some"
233
+ unless name =~ /^(\w+)(::)?Config$/
234
+ raise "Couldn't infer config name, please, specify it explicitly" \
235
+ "via `config_name :my_config`"
236
+ end
237
+
238
+ Regexp.last_match[1].tap(&:downcase!)
239
+ end
240
+
241
+ def validate_param_names!(names)
242
+ invalid_names = names.reject { |name| name =~ PARAM_NAME }
243
+ return if invalid_names.empty?
244
+
245
+ raise ArgumentError, "Invalid attr_config name: #{invalid_names.join(", ")}.\n" \
246
+ "Valid names must satisfy /#{PARAM_NAME.source}/."
247
+ end
248
+ end
249
+
250
+ on_load :validate_required_attributes!
251
+
252
+ attr_reader :config_name, :env_prefix
253
+
254
+ # Instantiate config instance.
255
+ #
256
+ # Example:
257
+ #
258
+ # my_config = Anyway::Config.new()
259
+ #
260
+ # # provide some values explicitly
261
+ # my_config = Anyway::Config.new({some: :value})
262
+ #
263
+ def initialize(overrides = nil)
264
+ @config_name = self.class.config_name
265
+
266
+ raise ArgumentError, "Config name is missing" unless @config_name
267
+
268
+ @env_prefix = self.class.env_prefix
269
+ @values = {}
270
+
271
+ load(overrides)
272
+ end
273
+
274
+ def reload(overrides = nil)
275
+ clear
276
+ load(overrides)
277
+ self
278
+ end
279
+
280
+ def clear
281
+ values.clear
282
+ @__trace__ = nil
283
+ self
284
+ end
285
+
286
+ def load(overrides = nil)
287
+ base_config = self.class.defaults.deep_dup
288
+
289
+ trace = Tracing.capture do
290
+ Tracing.trace!(:defaults) { base_config }
291
+
292
+ config_path = resolve_config_path(config_name, env_prefix)
293
+
294
+ load_from_sources(base_config, name: config_name, env_prefix: env_prefix, config_path: config_path)
295
+
296
+ if overrides
297
+ Tracing.trace!(:load) { overrides }
298
+
299
+ Utils.deep_merge!(base_config, overrides)
300
+ end
301
+ end
302
+
303
+ base_config.each do |key, val|
304
+ write_config_attr(key.to_sym, val)
305
+ end
306
+
307
+ # Trace may contain unknown attributes
308
+ trace&.keep_if { |key| self.class.config_attributes.include?(key.to_sym) }
309
+
310
+ # Run on_load callbacks
311
+ self.class.load_callbacks.each { _1.apply_to(self) }
312
+
313
+ # Set trace after we write all the values to
314
+ # avoid changing the source to accessor
315
+ @__trace__ = trace
316
+
317
+ self
318
+ end
319
+
320
+ def load_from_sources(base_config, **options)
321
+ Anyway.loaders.each do |(_id, loader)|
322
+ Utils.deep_merge!(base_config, loader.call(**options))
323
+ end
324
+ base_config
325
+ end
326
+
327
+ def dig(*keys) = values.dig(*keys)
328
+
329
+ def to_h() = values.deep_dup.deep_freeze
330
+
331
+ def dup
332
+ self.class.allocate.tap do |new_config|
333
+ %i[config_name env_prefix __trace__].each do |ivar|
334
+ new_config.instance_variable_set(:"@#{ivar}", send(ivar).dup)
335
+ end
336
+ new_config.instance_variable_set(:@values, values.deep_dup)
337
+ end
338
+ end
339
+
340
+ def resolve_config_path(name, env_prefix)
341
+ Anyway.env.fetch(env_prefix).delete("conf") || Settings.default_config_path.call(name)
342
+ end
343
+
344
+ def deconstruct_keys(keys) = values.deconstruct_keys(keys)
345
+
346
+ def to_source_trace() = __trace__&.to_h
347
+
348
+ def inspect
349
+ "#<#{self.class}:0x#{vm_object_id.rjust(16, "0")} config_name=\"#{config_name}\" env_prefix=\"#{env_prefix}\" " \
350
+ "values=#{values.inspect}>"
351
+ end
352
+
353
+ def pretty_print(q)
354
+ q.object_group self do
355
+ q.nest(1) do
356
+ q.breakable
357
+ q.text "config_name=#{config_name.inspect}"
358
+ q.breakable
359
+ q.text "env_prefix=#{env_prefix.inspect}"
360
+ q.breakable
361
+ q.text "values:"
362
+ q.pp __trace__
363
+ end
364
+ end
365
+ end
366
+
367
+ private
368
+
369
+ attr_reader :values, :__trace__
370
+
371
+ def validate_required_attributes!
372
+ self.class.required_attributes.select do |name|
373
+ values[name].nil? || (values[name].is_a?(String) && values[name].empty?)
374
+ end.then do |missing|
375
+ next if missing.empty?
376
+ raise_validation_error "The following config parameters are missing or empty: #{missing.join(", ")}"
377
+ end
378
+ end
379
+
380
+ def write_config_attr(key, val)
381
+ key = key.to_sym
382
+ return unless self.class.config_attributes.include?(key)
383
+
384
+ public_send(:"#{key}=", val)
385
+ end
386
+
387
+ def raise_validation_error(msg)
388
+ raise ValidationError, msg
389
+ end
390
+ end
391
+ end