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.
- checksums.yaml +5 -5
- data/CHANGELOG.md +17 -0
- data/README.md +332 -38
- data/lib/tty/config.rb +269 -67
- data/lib/tty/config/version.rb +3 -1
- data/spec/spec_helper.rb +54 -0
- data/spec/unit/alias_setting_spec.rb +72 -0
- data/spec/unit/append_spec.rb +26 -0
- data/spec/unit/autoload_env_spec.rb +62 -0
- data/spec/unit/delete_spec.rb +22 -0
- data/spec/unit/exist_spec.rb +24 -0
- data/spec/unit/fetch_spec.rb +45 -0
- data/spec/unit/generate_spec.rb +70 -0
- data/spec/unit/merge_spec.rb +13 -0
- data/spec/unit/new_spec.rb +6 -0
- data/spec/unit/normalize_hash_spec.rb +21 -0
- data/spec/unit/read_spec.rb +109 -0
- data/spec/unit/remove_spec.rb +16 -0
- data/spec/unit/set_from_env_spec.rb +78 -0
- data/spec/unit/set_if_empty_spec.rb +26 -0
- data/spec/unit/set_spec.rb +62 -0
- data/spec/unit/validate_spec.rb +76 -0
- data/spec/unit/write_spec.rb +197 -0
- data/tty-config.gemspec +4 -3
- metadata +35 -9
- data/.gitignore +0 -12
- data/.rspec +0 -3
- data/.travis.yml +0 -23
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -16
- data/appveyor.yml +0 -23
data/lib/tty/config.rb
CHANGED
@@ -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
|
-
|
54
|
-
|
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 =
|
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
|
-
#
|
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
|
-
#
|
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
|
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,
|
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
|
-
|
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
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
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
|
-
|
412
|
-
|
413
|
-
self.
|
414
|
-
|
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
|
418
|
-
|
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
|
425
|
-
|
607
|
+
when *EXTENSIONS[:json]
|
608
|
+
load_read_dep('json', ext)
|
426
609
|
JSON.parse(File.read(file))
|
427
|
-
when
|
428
|
-
|
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 #{
|
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
|
-
|
443
|
-
|
444
|
-
self.
|
445
|
-
|
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
|
449
|
-
|
450
|
-
|
451
|
-
when
|
452
|
-
|
453
|
-
|
454
|
-
when
|
455
|
-
|
456
|
-
|
457
|
-
|
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
|
-
|
463
|
-
|
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
|