collapsium-config 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,360 @@
1
+ # coding: utf-8
2
+ #
3
+ # collapsium-config
4
+ # https://github.com/jfinkhaeuser/collapsium-config
5
+ #
6
+ # Copyright (c) 2016 Jens Finkhaeuser and other collapsium-config contributors.
7
+ # All rights reserved.
8
+ #
9
+
10
+ require 'collapsium'
11
+
12
+ module Collapsium
13
+ module Config
14
+ ##
15
+ # The Config class extends UberHash by two main pieces of functionality:
16
+ #
17
+ # - it loads configuration files and turns them into pathed hashes, and
18
+ # - it treats environment variables as overriding anything contained in
19
+ # the configuration file.
20
+ #
21
+ # For configuration file loading, a named configuration file will be laoaded
22
+ # if present. A file with the same name but `-local` appended before the
23
+ # extension will be loaded as well, overriding any values in the original
24
+ # configuration file.
25
+ #
26
+ # For environment variable support, any environment variable named like a
27
+ # path into the configuration hash, but with separators transformed to
28
+ # underscore and all letters capitalized will override values from the
29
+ # configuration files under that path, i.e. `FOO_BAR` will override
30
+ # `'foo.bar'`.
31
+ #
32
+ # Environment variables can contain JSON *only*; if the value can be parsed
33
+ # as JSON, it becomes a Hash in the configuration tree. If it cannot be parsed
34
+ # as JSON, it remains a string.
35
+ #
36
+ # **Note:** if your configuration file's top-level structure is an array, it
37
+ # will be returned as a hash with a 'config' key that maps to your file's
38
+ # contents.
39
+ # That means that if you are trying to merge a hash with an array config, the
40
+ # result may be unexpected.
41
+ class Configuration < ::Collapsium::UberHash
42
+ # @api private
43
+ # Very simple YAML parser
44
+ class YAMLParser
45
+ require 'yaml'
46
+
47
+ # @return parsed string
48
+ def self.parse(string)
49
+ YAML.load(string)
50
+ end
51
+ end
52
+ private_constant :YAMLParser
53
+
54
+ # @api private
55
+ # Very simple JSON parser
56
+ class JSONParser
57
+ require 'json'
58
+
59
+ # @return parsed string
60
+ def self.parse(string)
61
+ JSON.parse(string)
62
+ end
63
+ end
64
+ private_constant :JSONParser
65
+
66
+ UberHash::READ_METHODS.each do |method|
67
+ # Wrap all read functions into something that checks for environment
68
+ # variables first.
69
+ define_method(method) do |*args, &block|
70
+ # If there are no arguments, there's nothing to do with paths. Just
71
+ # delegate to the hash.
72
+ if args.empty?
73
+ return super(*args, &block)
74
+ end
75
+
76
+ # We'll make it rather simple: since the first argument is a key, we
77
+ # will just transform it to the matching environment variable name,
78
+ # and see if that environment variable is set.
79
+ env_name = args[0].to_s.upcase.gsub(split_pattern, '_')
80
+ contents = nil
81
+ if env_name != '_'
82
+ contents = ENV[env_name]
83
+ end
84
+
85
+ # No environment variable set? Fine, just do the usual thing.
86
+ if contents.nil? or contents.empty?
87
+ return super(*args, &block)
88
+ end
89
+
90
+ # With an environment variable, we will try to parse it as JSON first.
91
+ begin
92
+ return JSONParser.parse(contents)
93
+ rescue JSON::ParserError
94
+ return contents
95
+ end
96
+ end
97
+ end
98
+
99
+ class << self
100
+ # @api private
101
+ # Mapping of file name extensions to parser types.
102
+ FILE_TO_PARSER = {
103
+ '.yml' => YAMLParser,
104
+ '.yaml' => YAMLParser,
105
+ '.json' => JSONParser,
106
+ }.freeze
107
+ private_constant :FILE_TO_PARSER
108
+
109
+ # @api private
110
+ # If the config file contains an Array, this is what they key of the
111
+ # returned Hash will be.
112
+ ARRAY_KEY = 'config'.freeze
113
+ private_constant :ARRAY_KEY
114
+
115
+ ##
116
+ # Loads a configuration file with the given file name. The format is
117
+ # detected based on one of the extensions in FILE_TO_PARSER.
118
+ #
119
+ # @param path [String] the path of the configuration file to load.
120
+ # @param resolve_extensions [Boolean] flag whether to resolve configuration
121
+ # hash extensions. (see `#resolve_extensions`)
122
+ def load_config(path, resolve_extensions = true)
123
+ # Load base and local configuration files
124
+ base, config = load_base_config(path)
125
+ _, local_config = load_local_config(base)
126
+
127
+ # Merge local configuration
128
+ config.recursive_merge!(local_config)
129
+
130
+ # Resolve includes
131
+ config = resolve_includes(base, config)
132
+
133
+ # Create config from the result
134
+ cfg = Configuration.new(config)
135
+
136
+ # Now resolve config hashes that extend other hashes.
137
+ if resolve_extensions
138
+ cfg.resolve_extensions!
139
+ end
140
+
141
+ return cfg
142
+ end
143
+
144
+ private
145
+
146
+ def load_base_config(path)
147
+ # Make sure the format is recognized early on.
148
+ base = Pathname.new(path)
149
+ formats = FILE_TO_PARSER.keys
150
+ if not formats.include?(base.extname)
151
+ raise ArgumentError, "Files with extension '#{base.extname}' are not"\
152
+ " recognized; please use one of #{formats}!"
153
+ end
154
+
155
+ # Don't check the path whether it exists - loading a nonexistent
156
+ # file will throw a nice error for the user to catch.
157
+ file = base.open
158
+ contents = file.read
159
+
160
+ # Parse the contents.
161
+ config = FILE_TO_PARSER[base.extname].parse(contents)
162
+
163
+ return base, UberHash.new(hashify(config))
164
+ end
165
+
166
+ def load_local_config(base)
167
+ # Now construct a file name for a local override.
168
+ local = Pathname.new(base.dirname)
169
+ local = local.join(base.basename(base.extname).to_s + "-local" +
170
+ base.extname)
171
+ if not local.exist?
172
+ return local, nil
173
+ end
174
+
175
+ # We know the local override file exists, but we do want to let any
176
+ # errors go through that come with reading or parsing it.
177
+ file = local.open
178
+ contents = file.read
179
+
180
+ local_config = FILE_TO_PARSER[base.extname].parse(contents)
181
+
182
+ return local, UberHash.new(hashify(local_config))
183
+ end
184
+
185
+ def hashify(data)
186
+ if data.nil?
187
+ return {}
188
+ end
189
+ if data.is_a? Array
190
+ data = { ARRAY_KEY => data }
191
+ end
192
+ return data
193
+ end
194
+
195
+ def resolve_includes(base, config)
196
+ processed = []
197
+ includes = []
198
+
199
+ loop do
200
+ # Figure out includes
201
+ outer_inc = extract_includes(config)
202
+ if not outer_inc.empty?
203
+ includes = outer_inc
204
+ end
205
+
206
+ to_process = includes - processed
207
+
208
+ # Stop resolving when all includes have been processed
209
+ if to_process.empty?
210
+ break
211
+ end
212
+
213
+ # Load and merge the include files
214
+ to_process.each do |filename|
215
+ incfile = Pathname.new(base.dirname)
216
+ incfile = incfile.join(filename)
217
+
218
+ # Just try to open it, if that errors out that's ok.
219
+ file = incfile.open
220
+ contents = file.read
221
+
222
+ parsed = FILE_TO_PARSER[incfile.extname].parse(contents)
223
+
224
+ # Extract and merge includes
225
+ inner_inc = extract_includes(parsed)
226
+ includes += inner_inc
227
+
228
+ # Merge the rest
229
+ config.recursive_merge!(UberHash.new(hashify(parsed)))
230
+
231
+ processed << filename
232
+ end
233
+ end
234
+
235
+ return config
236
+ end
237
+
238
+ def extract_includes(config)
239
+ # Figure out includes
240
+ includes = config.fetch("include", [])
241
+ config.delete("include")
242
+ includes = config.fetch(:include, includes)
243
+ config.delete(:include)
244
+
245
+ # We might have a simple/string include
246
+ if not includes.is_a? Array
247
+ includes = [includes]
248
+ end
249
+
250
+ return includes
251
+ end
252
+ end # class << self
253
+
254
+ ##
255
+ # Resolve extensions in configuration hashes. If your hash contains e.g.:
256
+ #
257
+ # ```yaml
258
+ # foo:
259
+ # bar:
260
+ # some: value
261
+ # baz:
262
+ # extends: bar
263
+ # ```
264
+ #
265
+ # Then `'foo.baz.some'` will equal `'value'` after resolving extensions. Note
266
+ # that `:load_config` calls this function, so normally you don't need to call
267
+ # it yourself. You can switch this behaviour off in `:load_config`.
268
+ #
269
+ # Note that this process has some intended side-effects:
270
+ #
271
+ # 1. If a hash can't be extended because the base cannot be found, an error
272
+ # is raised.
273
+ # 1. If a hash got successfully extended, the `extends` keyword itself is
274
+ # removed from the hash.
275
+ # 1. In a successfully extended hash, an `base` keyword, which contains
276
+ # the name of the base. In case of multiple recursive extensions, the
277
+ # final base is stored here.
278
+ #
279
+ # Also note that all of this means that :extends and :base are reserved
280
+ # keywords that cannot be used in configuration files other than for this
281
+ # purpose!
282
+ def resolve_extensions!
283
+ recursive_merge("", "")
284
+ end
285
+
286
+ private
287
+
288
+ def recursive_merge(parent, key)
289
+ loop do
290
+ full_key = "#{parent}#{separator}#{key}"
291
+
292
+ # Recurse down to the remaining root of the hierarchy
293
+ base = full_key
294
+ derived = nil
295
+ loop do
296
+ new_base, new_derived = resolve_extension(parent, base)
297
+
298
+ if new_derived.nil?
299
+ break
300
+ end
301
+
302
+ base = new_base
303
+ derived = new_derived
304
+ end
305
+
306
+ # If recursion found nothing to merge, we're done!
307
+ if derived.nil?
308
+ break
309
+ end
310
+
311
+ # Otherwise, merge what needs merging and continue
312
+ merge_extension(base, derived)
313
+ end
314
+ end
315
+
316
+ def resolve_extension(grandparent, parent)
317
+ fetch(parent, {}).each do |key, value|
318
+ # Recurse into hash values
319
+ if value.is_a? Hash
320
+ recursive_merge(parent, key)
321
+ end
322
+
323
+ # No hash, ignore any keys other than the special "extends" key
324
+ if key != "extends"
325
+ next
326
+ end
327
+
328
+ # If the key is "extends", return a normalized version of its value.
329
+ full_value = value.dup
330
+ if not full_value.start_with?(separator)
331
+ full_value = "#{grandparent}#{separator}#{value}"
332
+ end
333
+
334
+ if full_value == parent
335
+ next
336
+ end
337
+ return full_value, parent
338
+ end
339
+
340
+ return nil, nil
341
+ end
342
+
343
+ def merge_extension(base, derived)
344
+ # Remove old 'extends' key, but remember the value
345
+ extends = self[derived]["extends"]
346
+ self[derived].delete("extends")
347
+
348
+ # Recursively merge base into derived without overwriting
349
+ self[derived].extend(::Collapsium::RecursiveMerge)
350
+ self[derived].recursive_merge!(self[base], false)
351
+
352
+ # Then set the "base" keyword, but only if it's not yet set.
353
+ if not self[derived]["base"].nil?
354
+ return
355
+ end
356
+ self[derived]["base"] = extends
357
+ end
358
+ end # class Configuration
359
+ end # module Config
360
+ end # module Collapsium
@@ -0,0 +1,14 @@
1
+ # coding: utf-8
2
+ #
3
+ # collapsium-config
4
+ # https://github.com/jfinkhaeuser/collapsium-config
5
+ #
6
+ # Copyright (c) 2016 Jens Finkhaeuser and other collapsium-config contributors.
7
+ # All rights reserved.
8
+ #
9
+ module Collapsium
10
+ module Config
11
+ # The current release version
12
+ VERSION = "0.1.0".freeze
13
+ end # module Config
14
+ end # module Collapsium
@@ -0,0 +1,183 @@
1
+ require 'spec_helper'
2
+ require_relative '../lib/collapsium-config/configuration'
3
+
4
+ describe Collapsium::Config::Configuration do
5
+ before do
6
+ @data_path = File.join(File.dirname(__FILE__), 'data')
7
+ end
8
+
9
+ describe "basic file loading" do
10
+ it "fails to load a nonexistent file" do
11
+ expect { Collapsium::Config::Configuration.load_config("_nope_.yaml") }.to \
12
+ raise_error Errno::ENOENT
13
+ end
14
+
15
+ it "is asked to load an unrecognized extension" do
16
+ expect { Collapsium::Config::Configuration.load_config("_nope_.cfg") }.to \
17
+ raise_error ArgumentError
18
+ end
19
+
20
+ it "loads a yaml config with a top-level hash correctly" do
21
+ config = File.join(@data_path, 'hash.yml')
22
+ cfg = Collapsium::Config::Configuration.load_config(config)
23
+
24
+ expect(cfg["foo"]).to eql "bar"
25
+ expect(cfg["baz"]).to eql "quux"
26
+ end
27
+
28
+ it "loads a yaml config with a top-level array correctly" do
29
+ config = File.join(@data_path, 'array.yaml')
30
+ cfg = Collapsium::Config::Configuration.load_config(config)
31
+
32
+ expect(cfg["config"]).to eql %w(foo bar)
33
+ end
34
+
35
+ it "loads a JSON config correctly" do
36
+ config = File.join(@data_path, 'test.json')
37
+ cfg = Collapsium::Config::Configuration.load_config(config)
38
+
39
+ expect(cfg["foo"]).to eql "bar"
40
+ expect(cfg["baz"]).to eql 42
41
+ end
42
+
43
+ it "treats an empty YAML file as an empty hash" do
44
+ config = File.join(@data_path, 'empty.yml')
45
+ cfg = Collapsium::Config::Configuration.load_config(config)
46
+ expect(cfg).to be_empty
47
+ end
48
+ end
49
+
50
+ describe "merge behaviour" do
51
+ it "merges a hashed config correctly" do
52
+ config = File.join(@data_path, 'merge-hash.yml')
53
+ cfg = Collapsium::Config::Configuration.load_config(config)
54
+
55
+ expect(cfg["asdf"]).to eql 1
56
+ expect(cfg["foo.bar"]).to eql "baz"
57
+ expect(cfg["foo.quux"]).to eql [1, 42]
58
+ expect(cfg["foo.baz"]).to eql 3.14
59
+ expect(cfg["blargh"]).to eql false
60
+ end
61
+
62
+ it "merges an array config correctly" do
63
+ config = File.join(@data_path, 'merge-array.yaml')
64
+ cfg = Collapsium::Config::Configuration.load_config(config)
65
+
66
+ expect(cfg["config"]).to eql %w(foo bar baz)
67
+ end
68
+
69
+ it "merges an array and hash config" do
70
+ config = File.join(@data_path, 'merge-fail.yaml')
71
+ cfg = Collapsium::Config::Configuration.load_config(config)
72
+
73
+ expect(cfg["config"]).to eql %w(array in main config)
74
+ expect(cfg["local"]).to eql "override is a hash"
75
+ end
76
+
77
+ it "overrides configuration variables from the environment" do
78
+ config = File.join(@data_path, 'hash.yml')
79
+ cfg = Collapsium::Config::Configuration.load_config(config)
80
+
81
+ ENV["BAZ"] = "override"
82
+ expect(cfg["foo"]).to eql "bar"
83
+ expect(cfg["baz"]).to eql "override"
84
+ ENV.delete("BAZ")
85
+ end
86
+ end
87
+
88
+ describe "extend functionality" do
89
+ it "extends configuration hashes" do
90
+ config = File.join(@data_path, 'driverconfig.yml')
91
+ cfg = Collapsium::Config::Configuration.load_config(config)
92
+
93
+ # First, test for non-extended values
94
+ expect(cfg["drivers.mock.mockoption"]).to eql 42
95
+ expect(cfg["drivers.branch1.branch1option"]).to eql "foo"
96
+ expect(cfg["drivers.branch2.branch2option"]).to eql "bar"
97
+ expect(cfg["drivers.leaf.leafoption"]).to eql "baz"
98
+
99
+ # Now test extended values
100
+ expect(cfg["drivers.branch1.mockoption"]).to eql 42
101
+ expect(cfg["drivers.branch2.mockoption"]).to eql 42
102
+ expect(cfg["drivers.leaf.mockoption"]).to eql 42
103
+
104
+ expect(cfg["drivers.branch2.branch1option"]).to eql "foo"
105
+ expect(cfg["drivers.leaf.branch1option"]).to eql "override" # not "foo" !
106
+
107
+ expect(cfg["drivers.leaf.branch2option"]).to eql "bar"
108
+
109
+ # Also test that all levels go back to base == mock
110
+ expect(cfg["drivers.branch1.base"]).to eql 'mock'
111
+ expect(cfg["drivers.branch2.base"]).to eql 'mock'
112
+ expect(cfg["drivers.leaf.base"]).to eql 'mock'
113
+ end
114
+
115
+ it "extends configuration hashes when the base does not exist" do
116
+ config = File.join(@data_path, 'driverconfig.yml')
117
+ cfg = Collapsium::Config::Configuration.load_config(config)
118
+
119
+ # Ensure the hash contains its own value
120
+ expect(cfg["drivers.base_does_not_exist.some"]).to eql "value"
121
+
122
+ # Also ensure the "base" is set properly
123
+ expect(cfg["drivers.base_does_not_exist.base"]).to eql "nonexistent_base"
124
+ end
125
+
126
+ it "does nothing when a hash extends itself" do
127
+ config = File.join(@data_path, 'recurse.yml')
128
+ cfg = Collapsium::Config::Configuration.load_config(config)
129
+
130
+ # Most of the test is already over, i.e. we haven't run into recursion
131
+ # issues.
132
+ expect(cfg["extends_itself.test"]).to eql 42
133
+ end
134
+ end
135
+
136
+ describe "include functionality" do
137
+ it "can include a file" do
138
+ config = File.join(@data_path, 'include-simple.yml')
139
+ cfg = Collapsium::Config::Configuration.load_config(config)
140
+
141
+ expect(cfg["foo"]).to eql 42
142
+ expect(cfg["bar"]).to eql 'quux'
143
+ end
144
+
145
+ it "can include multiple files in different languages" do
146
+ config = File.join(@data_path, 'include-multiple.yml')
147
+ cfg = Collapsium::Config::Configuration.load_config(config)
148
+
149
+ expect(cfg["foo"]).to eql 42
150
+ expect(cfg["bar"]).to eql 'quux'
151
+ expect(cfg["baz"]).to eql 'test'
152
+ end
153
+
154
+ it "can resolve includes recursively" do
155
+ config = File.join(@data_path, 'include-recursive.yml')
156
+ cfg = Collapsium::Config::Configuration.load_config(config)
157
+
158
+ expect(cfg["foo"]).to eql 42
159
+ expect(cfg["bar"]).to eql 'quux'
160
+ expect(cfg["baz"]).to eql 'test'
161
+ end
162
+
163
+ it "extends configuration from across includes" do
164
+ config = File.join(@data_path, 'include-extend.yml')
165
+ cfg = Collapsium::Config::Configuration.load_config(config)
166
+
167
+ expect(cfg["foo.bar"]).to eql 'quux'
168
+ expect(cfg["foo.baz"]).to eql 'test'
169
+ expect(cfg["bar.foo"]).to eql 'something'
170
+ expect(cfg["bar.baz"]).to eql 42
171
+ end
172
+ end
173
+
174
+ describe "behaves like a UberHash" do
175
+ it "passed through access methods" do
176
+ config = File.join(@data_path, 'hash.yml')
177
+ cfg = Collapsium::Config::Configuration.load_config(config)
178
+
179
+ # UberHash's [] requires one argument
180
+ expect { cfg[] }.to raise_error(ArgumentError)
181
+ end
182
+ end
183
+ end