collapsium-config 0.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.
@@ -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