tty-config 0.2.0 → 0.3.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.
@@ -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