tty-config 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -30,6 +30,65 @@ module TTY
30
30
  end
31
31
  end
32
32
 
33
+ # Generate file content based on the data hash
34
+ #
35
+ # @param [Hash] data
36
+ #
37
+ # @return [String]
38
+ # the file content
39
+ #
40
+ # @api public
41
+ def self.generate(data, separator: '=')
42
+ content = []
43
+ values = {}
44
+ sections = {}
45
+
46
+ data.keys.sort.each do |key|
47
+ val = data[key]
48
+ if val.is_a?(NilClass)
49
+ next
50
+ elsif val.is_a?(Hash) ||
51
+ (val.is_a?(Array) && val.first.is_a?(Hash))
52
+ sections[key] = val
53
+ elsif val.is_a?(Array)
54
+ values[key] = val.join(',')
55
+ else
56
+ values[key] = val
57
+ end
58
+ end
59
+
60
+ # values
61
+ values.each do |key, val|
62
+ content << "#{key} #{separator} #{val}"
63
+ end
64
+ content << '' unless values.empty?
65
+
66
+ # sections
67
+ sections.each do |section, object|
68
+ next if object.empty? # only add section if values present
69
+
70
+ content << "[#{section}]"
71
+ if object.is_a?(Array)
72
+ object = object.reduce({}, :merge!)
73
+ end
74
+ object.each do |key, val|
75
+ val = val.join(',') if val.is_a?(Array)
76
+ content << "#{key} #{separator} #{val}" if val
77
+ end
78
+ content << ''
79
+ end
80
+ content.join("\n")
81
+ end
82
+
83
+ # Storage for suppported format & extensions pairs
84
+ # @api public
85
+ EXTENSIONS = {
86
+ yaml: %w(.yaml .yml),
87
+ json: %w(.json),
88
+ toml: %w(.toml),
89
+ ini: %w(.ini .cnf .conf .cfg .cf)
90
+ }.freeze
91
+
33
92
  # A collection of config paths
34
93
  # @api public
35
94
  attr_reader :location_paths
@@ -50,14 +109,25 @@ module TTY
50
109
  # @api public
51
110
  attr_reader :validators
52
111
 
53
- def initialize(settings = {})
54
- @location_paths = []
112
+ # The prefix used for searching ENV variables
113
+ # @api public
114
+ attr_accessor :env_prefix
115
+
116
+ # Create a configuration instance
117
+ #
118
+ # @api public
119
+ def initialize(**settings)
55
120
  @settings = settings
121
+ @location_paths = []
56
122
  @validators = {}
57
123
  @filename = 'config'
58
124
  @extname = '.yml'
59
- @extensions = ['.yaml', '.yml', '.json', '.toml']
125
+ @extensions = EXTENSIONS.values.flatten << ''
60
126
  @key_delim = '.'
127
+ @envs = {}
128
+ @env_prefix = ''
129
+ @autoload_env = false
130
+ @aliases = {}
61
131
 
62
132
  yield(self) if block_given?
63
133
  end
@@ -88,6 +158,20 @@ module TTY
88
158
  @location_paths.unshift(path)
89
159
  end
90
160
 
161
+ # Check if env variables are auto loaded
162
+ #
163
+ # @api public
164
+ def autoload_env?
165
+ @autoload_env == true
166
+ end
167
+
168
+ # Auto load env variables
169
+ #
170
+ # @api public
171
+ def autoload_env
172
+ @autoload_env = true
173
+ end
174
+
91
175
  # Set a value for a composite key and overrides any existing keys.
92
176
  # Keys are case-insensitive
93
177
  #
@@ -123,6 +207,34 @@ module TTY
123
207
  block ? set(*keys, &block) : set(*keys, value: value)
124
208
  end
125
209
 
210
+ # Bind a key to ENV variable
211
+ #
212
+ # @example
213
+ # set_from_env(:host)
214
+ # set_from_env(:foo, :bar) { 'HOST' }
215
+ #
216
+ # @param [Array[String]] keys
217
+ # the keys to bind to ENV variables
218
+ #
219
+ # @api public
220
+ def set_from_env(*keys, &block)
221
+ assert_keys_with_block(convert_to_keys(keys), block)
222
+ key = flatten_keys(keys)
223
+ env_key = block.nil? ? key : block.()
224
+ env_key = to_env_key(env_key)
225
+ @envs[key.to_s.downcase] = env_key
226
+ end
227
+
228
+ # Convert config key to standard ENV var name
229
+ #
230
+ # @param [String] key
231
+ #
232
+ # @api private
233
+ def to_env_key(key)
234
+ env_key = key.to_s.upcase
235
+ @env_prefix == '' ? env_key : "#{@env_prefix.to_s.upcase}_#{env_key}"
236
+ end
237
+
126
238
  # Fetch value under a composite key
127
239
  #
128
240
  # @param [Array[String|Symbol]] keys
@@ -131,9 +243,21 @@ module TTY
131
243
  #
132
244
  # @api public
133
245
  def fetch(*keys, default: nil, &block)
246
+ # check alias
247
+ real_key = @aliases[flatten_keys(keys)]
248
+ keys = real_key.split(key_delim) if real_key
249
+
134
250
  keys = convert_to_keys(keys)
251
+ env_key = autoload_env? ? to_env_key(keys[0]) : @envs[flatten_keys(keys)]
252
+ # first try settings
135
253
  value = deep_fetch(@settings, *keys)
254
+ # then try ENV var
255
+ if value.nil? && env_key
256
+ value = ENV[env_key]
257
+ end
258
+ # then try default
136
259
  value = block || default if value.nil?
260
+
137
261
  while callable_without_params?(value)
138
262
  value = value.call
139
263
  end
@@ -182,7 +306,38 @@ module TTY
182
306
  deep_delete(*keys, @settings)
183
307
  end
184
308
 
185
- # Register validation for a nested key
309
+ # Define an alias to a nested key
310
+ #
311
+ # @example
312
+ # alias_setting(:foo, to: :bar)
313
+ #
314
+ # @param [Array[String]] keys
315
+ # the alias key
316
+ #
317
+ # @api public
318
+ def alias_setting(*keys, to: nil)
319
+ flat_setting = flatten_keys(keys)
320
+ alias_keys = Array(to)
321
+ alias_key = flatten_keys(alias_keys)
322
+
323
+ if alias_key == flat_setting
324
+ raise ArgumentError, 'Alias matches setting key'
325
+ end
326
+
327
+ if fetch(alias_key)
328
+ raise ArgumentError, 'Setting already exists with an alias ' \
329
+ "'#{alias_keys.map(&:inspect).join(', ')}'"
330
+ end
331
+
332
+ @aliases[alias_key] = flat_setting
333
+ end
334
+
335
+ # Register a validation rule for a nested key
336
+ #
337
+ # @param [Array[String]] keys
338
+ # a deep nested keys
339
+ # @param [Proc] validator
340
+ # the logic to use to validate given nested key
186
341
  #
187
342
  # @api public
188
343
  def validate(*keys, &validator)
@@ -192,26 +347,8 @@ module TTY
192
347
  validators[key] = values
193
348
  end
194
349
 
195
- # Check if key passes all registered validations
350
+ # Find configuration file matching filename and extension
196
351
  #
197
- # @api private
198
- def assert_valid(key, value)
199
- validators[key].each do |validator|
200
- validator.call(key, value)
201
- end
202
- end
203
-
204
- # Delay key validation
205
- #
206
- # @api private
207
- def delay_validation(key, callback)
208
- -> do
209
- val = callback.()
210
- assert_valid(key, val)
211
- val
212
- end
213
- end
214
-
215
352
  # @api private
216
353
  def find_file
217
354
  @location_paths.each do |location_path|
@@ -227,9 +364,10 @@ module TTY
227
364
  # @return [Boolean]
228
365
  #
229
366
  # @api public
230
- def persisted?
367
+ def exist?
231
368
  !find_file.nil?
232
369
  end
370
+ alias persisted? exist?
233
371
 
234
372
  # Find and read a configuration file.
235
373
  #
@@ -239,17 +377,20 @@ module TTY
239
377
  # @param [String] file
240
378
  # the path to the configuration file to be read
241
379
  #
380
+ # @param [String] format
381
+ # the format to read configuration in
382
+ #
242
383
  # @raise [TTY::Config::ReadError]
243
384
  #
244
385
  # @api public
245
- def read(file = find_file)
386
+ def read(file = find_file, format: :auto)
246
387
  if file.nil?
247
- raise ReadError, "No file found to read configuration from!"
388
+ raise ReadError, 'No file found to read configuration from!'
248
389
  elsif !::File.exist?(file)
249
390
  raise ReadError, "Configuration file `#{file}` does not exist!"
250
391
  end
251
392
 
252
- merge(unmarshal(file))
393
+ merge(unmarshal(file, format: format))
253
394
  end
254
395
 
255
396
  # Write current configuration to a file.
@@ -258,11 +399,11 @@ module TTY
258
399
  # the path to a file
259
400
  #
260
401
  # @api public
261
- def write(file = find_file, force: false)
402
+ def write(file = find_file, force: false, format: :auto)
262
403
  if file && ::File.exist?(file)
263
404
  if !force
264
405
  raise WriteError, "File `#{file}` already exists. " \
265
- "Use :force option to overwrite."
406
+ 'Use :force option to overwrite.'
266
407
  elsif !::File.writable?(file)
267
408
  raise WriteError, "Cannot write to #{file}."
268
409
  end
@@ -273,7 +414,8 @@ module TTY
273
414
  file = ::File.join(dir, "#{filename}#{@extname}")
274
415
  end
275
416
 
276
- marshal(file, @settings)
417
+ content = marshal(file, @settings, format: format)
418
+ ::File.write(file, content)
277
419
  end
278
420
 
279
421
  # Current configuration
@@ -286,16 +428,57 @@ module TTY
286
428
 
287
429
  private
288
430
 
431
+ # Ensure that value is set either through parameter or block
432
+ #
433
+ # @api private
434
+ def assert_either_value_or_block(value, block)
435
+ if value.nil? && block.nil?
436
+ raise ArgumentError, 'Need to set either value or block'
437
+ elsif !(value.nil? || block.nil?)
438
+ raise ArgumentError, "Can't set both value and block"
439
+ end
440
+ end
441
+
442
+ # Check if object is a proc with no arguments
443
+ #
444
+ # @return [Boolean]
445
+ #
446
+ # @api private
289
447
  def callable_without_params?(object)
290
448
  object.respond_to?(:call) &&
291
449
  (!object.respond_to?(:arity) || object.arity.zero?)
292
450
  end
293
451
 
294
- def assert_either_value_or_block(value, block)
295
- if value.nil? && block.nil?
296
- raise ArgumentError, "Need to set either value or block"
297
- elsif !(value.nil? || block.nil?)
298
- raise ArgumentError, "Can't set both value and block"
452
+ # Wrap callback in a proc object that includes validation
453
+ # that will be performed at point when a new proc is invoked.
454
+ #
455
+ # @param [String] key
456
+ # @param [Proc] callback
457
+ #
458
+ # @api private
459
+ def delay_validation(key, callback)
460
+ -> do
461
+ val = callback.()
462
+ assert_valid(key, val)
463
+ val
464
+ end
465
+ end
466
+
467
+ # Check if key passes all registered validations for a key
468
+ #
469
+ # @param [String] key
470
+ # @param [Object] value
471
+ #
472
+ # @api private
473
+ def assert_valid(key, value)
474
+ validators[key].each do |validator|
475
+ validator.call(key, value)
476
+ end
477
+ end
478
+
479
+ def assert_keys_with_block(keys, block)
480
+ if keys.size > 1 && block.nil?
481
+ raise ArgumentError, 'Need to set env var in block'
299
482
  end
300
483
  end
301
484
 
@@ -407,61 +590,80 @@ module TTY
407
590
  end
408
591
 
409
592
  # @api private
410
- def unmarshal(file)
411
- ext = ::File.extname(file)
412
- self.extname = ext
413
- self.filename = ::File.basename(file, ext)
414
- gem_name = nil
593
+ def unmarshal(file, format: :auto)
594
+ file_ext = ::File.extname(file)
595
+ ext = (format == :auto ? file_ext : ".#{format}")
596
+ self.extname = file_ext
597
+ self.filename = ::File.basename(file, file_ext)
415
598
 
416
599
  case ext
417
- when '.yaml', '.yml'
418
- require 'yaml'
600
+ when *EXTENSIONS[:yaml]
601
+ load_read_dep('yaml', ext)
419
602
  if YAML.respond_to?(:safe_load)
420
603
  YAML.safe_load(File.read(file))
421
604
  else
422
605
  YAML.load(File.read(file))
423
606
  end
424
- when '.json'
425
- require 'json'
607
+ when *EXTENSIONS[:json]
608
+ load_read_dep('json', ext)
426
609
  JSON.parse(File.read(file))
427
- when '.toml'
428
- gem_name = 'toml'
429
- require 'toml'
610
+ when *EXTENSIONS[:toml]
611
+ load_read_dep('toml', ext)
430
612
  TOML.load(::File.read(file))
613
+ when *EXTENSIONS[:ini]
614
+ load_read_dep('inifile', ext)
615
+ ini = IniFile.load(file).to_h
616
+ global = ini.delete('global')
617
+ ini.merge!(global)
431
618
  else
432
619
  raise ReadError, "Config file format `#{ext}` is not supported."
433
620
  end
621
+ end
622
+
623
+ # Try loading read dependency
624
+ # @api private
625
+ def load_read_dep(gem_name, format)
626
+ require gem_name
434
627
  rescue LoadError
435
- puts "Please install `#{gem_name}`"
436
628
  raise ReadError, "Gem `#{gem_name}` is missing. Please install it " \
437
- "to read #{ext} configuration format."
629
+ "to read #{format} configuration format."
438
630
  end
439
631
 
632
+ # Marshal data hash into a configuration file content
633
+ #
634
+ # @return [String]
635
+ #
440
636
  # @api private
441
- def marshal(file, data)
442
- ext = ::File.extname(file)
443
- self.extname = ext
444
- self.filename = ::File.basename(file, ext)
445
- gem_name = nil
637
+ def marshal(file, data, format: :auto)
638
+ file_ext = ::File.extname(file)
639
+ ext = (format == :auto ? file_ext : ".#{format}")
640
+ self.extname = file_ext
641
+ self.filename = ::File.basename(file, file_ext)
446
642
 
447
643
  case ext
448
- when '.yaml', '.yml'
449
- require 'yaml'
450
- ::File.write(file, YAML.dump(self.class.normalize_hash(data, :to_s)))
451
- when '.json'
452
- require 'json'
453
- ::File.write(file, JSON.pretty_generate(data))
454
- when '.toml'
455
- gem_name = 'toml'
456
- require 'toml'
457
- ::File.write(file, TOML::Generator.new(data).body)
644
+ when *EXTENSIONS[:yaml]
645
+ load_write_dep('yaml', ext)
646
+ YAML.dump(self.class.normalize_hash(data, :to_s))
647
+ when *EXTENSIONS[:json]
648
+ load_write_dep('json', ext)
649
+ JSON.pretty_generate(data)
650
+ when *EXTENSIONS[:toml]
651
+ load_write_dep('toml', ext)
652
+ TOML::Generator.new(data).body
653
+ when *EXTENSIONS[:ini]
654
+ Config.generate(data)
458
655
  else
459
656
  raise WriteError, "Config file format `#{ext}` is not supported."
460
657
  end
658
+ end
659
+
660
+ # Try loading write depedency
661
+ # @api private
662
+ def load_write_dep(gem_name, format)
663
+ require gem_name
461
664
  rescue LoadError
462
- puts "Please install `#{gem_name}`"
463
- raise ReadError, "Gem `#{gem_name}` is missing. Please install it " \
464
- "to read #{ext} configuration format."
665
+ raise WriteError, "Gem `#{gem_name}` is missing. Please install it " \
666
+ "to read #{format} configuration format."
465
667
  end
466
668
  end # Config
467
669
  end # TTY