jac 0.0.1

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,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 85117aeca4175be823c52ff25aa33e0ebaf6efdf
4
+ data.tar.gz: b8c98ee89f3a3818cfa906f26e8ac3189282fd18
5
+ SHA512:
6
+ metadata.gz: 7f9f97b2551ec8c5ec763673b615cfba497e434e4a3004e396cf106bd080c53398fbea623f3051318097fd419265a3c601e054abf805029b30250780c65b9ce3
7
+ data.tar.gz: 4f26c83218fffd715448ca87ba96e39d99a17efe702bde663e9aa2c3fb6f3cae20878177dbcac0c3ad97dbace423b57d8ff88a8f36bfb0dae6cbc738f9d3d377
@@ -0,0 +1,3 @@
1
+ require_relative 'jac/configuration'
2
+ require_relative 'jac/merger'
3
+ require_relative 'jac/parser'
@@ -0,0 +1,407 @@
1
+ require 'yaml'
2
+ require 'ostruct'
3
+
4
+ module Jac
5
+ # Configuration is loaded from well formed YAML streams.
6
+ # Each document expected to be key-value mapping where
7
+ # keys a `profile` names and values is a profile content.
8
+ # Profile itself is key-value mapping too. Except reserved
9
+ # key names (i.e. `extends`) each key in profile is a
10
+ # configuration field. For example following yaml document
11
+ #
12
+ # ```
13
+ #
14
+ # foo:
15
+ # bar: 42
16
+ # qoo:
17
+ # bar: 32
18
+ #
19
+ # ```
20
+ #
21
+ # represents description of two profiles, `foo` and `qoo`,
22
+ # where field `bar` is set to `42` and `32` respectively.
23
+ #
24
+ # Profile can be constructed using combination of other profiles
25
+ # for example having `debug` and `release` profiles for testing
26
+ # and production. And having `remote` and `local` profiles for
27
+ # building on local or remote machine. We cant get `debug,local`,
28
+ # `debug,remote`, `release,local` and `release,remote` profiles.
29
+ # Each of such profiles is a result of merging values of listed
30
+ # profiles. When merging profile with another configuration
31
+ # resolver overwrites existing fields. For example if `debug`
32
+ # and `local` for some reason have same field. In profile
33
+ # `debug,local` value from `debug` profile will be overwritten
34
+ # with value from `local` profile.
35
+ #
36
+ # ## Extending profiles
37
+ #
38
+ # One profile can `extend` another. Or any amount of other
39
+ # profiles. To specify this one should use `extends` field
40
+ # like that
41
+ #
42
+ # ```
43
+ #
44
+ # base:
45
+ # application_name: my-awesome-app
46
+ # port: 80
47
+ #
48
+ # version:
49
+ # version_name: 0.0.0
50
+ # version_code: 42
51
+ #
52
+ # debug:
53
+ # extends: [base, version]
54
+ # port: 9292
55
+ # ```
56
+ #
57
+ # In this example `debug` will receive following fields:
58
+ #
59
+ # ```
60
+ # application_name: my-awesome-app # from base profile
61
+ # port: 9292 # from debug profile
62
+ # version_name: 0.0.0 # from version profile
63
+ # version_code: 42 # from version profile
64
+ # ```
65
+ #
66
+ # ## Merging multiple configuration files
67
+ #
68
+ # Configuration can be loaded from multiple YAML documents.
69
+ # Before resolve requested profile all described profiles
70
+ # are merged down together. Having sequence of files like
71
+ # `.application.yml`, `.application.user.yml` with following content
72
+ #
73
+ # ```
74
+ # # .application.yml
75
+ # base:
76
+ # user: deployer
77
+ #
78
+ # debug:
79
+ # extends: base
80
+ # # ... other values
81
+ # ```
82
+ #
83
+ # ```
84
+ # # .application.user.yml
85
+ # base:
86
+ # user: developer
87
+ # ```
88
+ #
89
+ # We'll get `user` field overwritten with value from
90
+ # `.application.user.yml`. And only after that construction
91
+ # of resulting profile will begin (for example `debug`)
92
+ #
93
+ # ## String evaluation
94
+ #
95
+ # Configuration resolver comes with powerful yet dangerous
96
+ # feature: it allows evaluate strings as ruby expressions
97
+ # like this:
98
+ #
99
+ # ```
100
+ # foo:
101
+ # build_time: "#{Time.now}" # Will be evaluated at configuration resolving step
102
+ # ```
103
+ #
104
+ # Configuration values are available to and can be referenced with `c`:
105
+ #
106
+ # ```
107
+ # base:
108
+ # application_name: my-awesome-app
109
+ # debug:
110
+ # extends: base
111
+ # server_name: "#{c.application_name}-debug" # yields to my-awesome-app-debug
112
+ # release:
113
+ # extends: base
114
+ # server_name: "#{c.application_name}-release" # yields to my-awesome-app-release
115
+ # ```
116
+ #
117
+ # All strings evaluated **after** profile is constructed thus
118
+ # you don't need to have declared values in current profile
119
+ # but be ready to get `nil`.
120
+ #
121
+ # ## Merging values
122
+ #
123
+ # By default if one value definition overwrites another
124
+ # ### Merging hashes
125
+ #
126
+ #
127
+ # ### Merging sets
128
+ #
129
+ #
130
+ # ## Generic profiles
131
+ #
132
+ # Same result as shown above can be achieved with generic profiles. Generic profile
133
+ # is a profile which name is regex (i.e. contained in `/.../`):
134
+ #
135
+ # ```
136
+ # base:
137
+ # application_name: my-awesome-app
138
+ # /(release|debug)/: # Profile name is a regex, with single capture (1)
139
+ # extends: base
140
+ # server_name: "#{c.application_name}-#{c.captures[1]}" # yields my-awesome-app-release or my-awesome-app-debug
141
+ # ```
142
+ #
143
+ # If profile name matches multiple generic profiles it not defined
144
+ # which profile will be used.
145
+ module Configuration
146
+ # Reads and evaluates configuration for given set of streams
147
+ # and profile
148
+ class ConfigurationReader
149
+ # Any configuration set always contains `default` profile
150
+ # which is loaded when no profile requested.
151
+ DEFAULT_PROFILE_NAME = 'default'.freeze
152
+ # Creates "empty" config
153
+ DEFAULT_CONFIGURATION = -> () { { DEFAULT_PROFILE_NAME => {} } }
154
+ # Creates configuration reader
155
+ # @param streams [Array] of pairs containing YAML document and provided
156
+ # name for this stream
157
+ attr_reader :merger
158
+ def initialize(streams)
159
+ @streams = streams
160
+ @merger = Merger.new
161
+ end
162
+
163
+ # Parses all streams and resolves requested profile
164
+ # @param profile [Array] list of profile names to be
165
+ # merged
166
+ # @return [OpenStruct] instance which contains all resolved profile fields
167
+ def read(*profile)
168
+ result = @streams
169
+ .flat_map { |stream, _name| read_stream(stream) }
170
+ .inject(DEFAULT_CONFIGURATION.call) { |acc, elem| update(acc, elem) }
171
+ OpenStruct.new(evaluate(resolve(profile, result)).merge('profile' => profile))
172
+ end
173
+
174
+ private
175
+
176
+ def read_stream(stream)
177
+ # Each stream consists of one or more documents
178
+ YAML.parse_stream(stream).children.flat_map do |document|
179
+ # Will use separate visitor per YAML document.
180
+ visitor = Only::Parser::VisitorToRuby.create
181
+ # Expecting document to be single mapping
182
+ profile_mapping = document.children.first
183
+ raise(ArgumentError, 'Mapping expected') unless profile_mapping.is_a? Psych::Nodes::Mapping
184
+ # Then mapping should be expanded to (key, value) pairs. Because yaml overwrites
185
+ # values for duplicated keys. This is not desired behaviour. We need to merge
186
+ # such entries
187
+ profile_mapping
188
+ .children
189
+ .each_slice(2)
190
+ .map { |k, v| { visitor.accept(k) => visitor.accept(v) } }
191
+ end
192
+ end
193
+
194
+ def update(config, config_part)
195
+ config_part.each do |profile, values|
196
+ profile_values = config[profile]
197
+ unless profile_values
198
+ profile_values = {}
199
+ config[profile] = profile_values
200
+ end
201
+ merge!(profile_values, values)
202
+ end
203
+
204
+ config
205
+ end
206
+
207
+ # Merges two hash structures using following rules
208
+ # @param [Hash] base value mappings
209
+ # @param [Hash] values ovverides.
210
+ # @return [Hash] merged profile
211
+ def merge!(base, overrides)
212
+ merger.merge!(base, overrides)
213
+ end
214
+
215
+ def resolve(profile, config)
216
+ ProfileResolver.new(config).resolve(profile)
217
+ end
218
+
219
+ def evaluate(resolved_profile)
220
+ ConfigurationEvaluator.evaluate(resolved_profile)
221
+ end
222
+ end
223
+
224
+ # Describes profile resolving strategy
225
+ class ProfileResolver
226
+ # Key where all inherited profiles listed
227
+ EXTENDS_KEY = 'extends'.freeze
228
+
229
+ attr_reader :config, :merger
230
+
231
+ def initialize(config)
232
+ @config = config
233
+ @merger = Merger.new
234
+ end
235
+
236
+ def resolve(profile, resolved = [])
237
+ profile.inject({}) do |acc, elem|
238
+ if resolved.include?(elem)
239
+ msg = 'Cyclic dependency found ' + (resolved + [elem]).join(' -> ')
240
+ raise(ArgumentError, msg)
241
+ end
242
+ profile_values = find_profile(elem)
243
+ # Find all inheritors
244
+ extends = *profile_values[EXTENDS_KEY] || []
245
+ # We can do not check extends. Empty profile returns {}
246
+ # Inherited values goes first
247
+ inherited = merger.merge(acc, resolve(extends, resolved + [elem]))
248
+ merger.merge(inherited, profile_values)
249
+ end
250
+ end
251
+
252
+ def find_profile(profile_name)
253
+ return config[profile_name] if config.key?(profile_name)
254
+ # First and last chars are '/'
255
+ match, matched_profile = find_generic_profile(profile_name)
256
+ # Generating profile
257
+ return generate_profile(match, matched_profile, profile_name) if match
258
+ raise(ArgumentError, 'No such profile ' + profile_name)
259
+ end
260
+
261
+ def generate_profile(match, matched_profile, profile_name)
262
+ gen_profile = {}
263
+ gen_profile['captures'] = match.captures if match.captures
264
+ gen_profile['named_captures'] = match.named_captures if match.named_captures
265
+ gen_profile.merge!(config[matched_profile])
266
+
267
+ config[profile_name] = gen_profile
268
+ end
269
+
270
+ # @todo print warning if other matching generic profiles found
271
+ def find_generic_profile(profile_name)
272
+ generic_profiles(config)
273
+ .detect do |profile, regex|
274
+ m = regex.match(profile_name)
275
+ break [m, profile] if m
276
+ end
277
+ end
278
+
279
+ def generic_profiles(config)
280
+ # Create generic profiles if missing
281
+ @generic_profiles ||= config
282
+ .keys
283
+ .select { |k| k[0] == '/' && k[-1] == '/' }
284
+ .map { |k| [k, Regexp.new(k[1..-2])] }
285
+
286
+ @generic_profiles
287
+ end
288
+ end
289
+
290
+ # Proxy class for getting actual values
291
+ # when referencing profile inside evaluated
292
+ # expressions
293
+ class EvaluationContext
294
+ def initialize(evaluator)
295
+ @evaluator = evaluator
296
+ end
297
+
298
+ def respond_to_missing?(_meth, _args, &_block)
299
+ true
300
+ end
301
+
302
+ def method_missing(meth, *args, &block)
303
+ # rubocop ispection hack
304
+ return super unless respond_to_missing?(meth, args, &block)
305
+ @evaluator.evaluate(meth.to_s)
306
+ end
307
+ end
308
+
309
+ # Evaluates all strings inside resolved profile
310
+ # object
311
+ class ConfigurationEvaluator
312
+ def initialize(src_object, dst_object)
313
+ @object = src_object
314
+ @evaluated = dst_object
315
+ @context = EvaluationContext.new(self)
316
+ resolve_object
317
+ end
318
+
319
+ def evaluate(key)
320
+ return @evaluated[key] if @evaluated.key? key
321
+ @evaluated[key] = evaluate_deep(@object[key])
322
+ end
323
+
324
+ def c
325
+ @context
326
+ end
327
+
328
+ alias config c
329
+ alias conf c
330
+ alias cfg c
331
+
332
+ private
333
+
334
+ def resolve_object
335
+ @object.each_key { |k| evaluate(k) }
336
+ # Cleanup accidentally created values (when referencing missing values)
337
+ @evaluated.delete_if { |k, _v| !@object.key?(k) }
338
+ end
339
+
340
+ def get_binding(obj)
341
+ binding
342
+ end
343
+
344
+ def evaluate_deep(object)
345
+ case object
346
+ when String
347
+ eval_string(object)
348
+ when Array
349
+ object.map { |e| evaluate_deep(e) }
350
+ when Hash
351
+ # Evaluating values only by convention
352
+ object.inject({}) { |acc, elem| acc.update(elem.first => evaluate_deep(elem.last)) }
353
+ else
354
+ object
355
+ end
356
+ end
357
+
358
+ def eval_string(o)
359
+ evaluated = /#\{.+?\}/.match(o) do
360
+ eval('"' + o + '"', get_binding(self))
361
+ end
362
+
363
+ evaluated || o
364
+ end
365
+
366
+ class << self
367
+ def evaluate(o)
368
+ dst = {}
369
+ ConfigurationEvaluator.new(o, dst)
370
+ dst
371
+ end
372
+ end
373
+ end
374
+
375
+ # List of files where configuration can be placed
376
+ # * `application.yml` - expected to be main configuration file.
377
+ # Usually it placed under version control.
378
+ # * `application.user.yml` - user defined overrides for main
379
+ # configuration, sensitive data which can't be placed
380
+ # under version control.
381
+ # * `application.override.yml` - final overrides.
382
+ CONFIGURATION_FILES = %w[application.yml application.user.yml application.override.yml].freeze
383
+
384
+ class << self
385
+ # Generates configuration object for given profile
386
+ # and list of streams with YAML document
387
+ # @param profile [Array] list of profile names to merge
388
+ # @param streams [Array] list of YAML documents and their
389
+ # names to read
390
+ # @return [OpenStruct] instance which contains all resolved profile fields
391
+ def read(profile, *streams)
392
+ profile = [ConfigurationReader::DEFAULT_PROFILE_NAME] if profile.empty?
393
+ ConfigurationReader.new(streams).read(*profile)
394
+ end
395
+
396
+ # Read configuration from configuration files.
397
+ def load(profile, files: CONFIGURATION_FILES, dir: Dir.pwd)
398
+ # Read all known files
399
+ streams = files
400
+ .map { |f| [File.join(dir, f), f] }
401
+ .select { |path, _name| File.exist?(path) }
402
+ .map { |path, name| [IO.read(path), name] }
403
+ read(profile, *streams)
404
+ end
405
+ end
406
+ end
407
+ end
@@ -0,0 +1,63 @@
1
+ require 'set'
2
+
3
+ module Jac
4
+ module Configuration
5
+ # Merges two hashes with following value resolve strategy:
6
+ # * When having both `that: Hash` and `other: Hash` for same key will merge
7
+ # them with same strategy and return result
8
+ # * When having Set and Enumerable will join two sets into
9
+ # one
10
+ # * When having Set and nil will return Set
11
+ # * Return `other` value in all other cases
12
+ class Merger
13
+ # Returns a new hash with base and overrides merged recursively.
14
+ # @param [Hash] base values
15
+ # @param [Hash] other values
16
+ # @return [Hash] updated hash
17
+ def merge(base, other)
18
+ merge!(base.dup, other)
19
+ end
20
+
21
+ # Returns a new hash with base and overrides merged recursively. Updates
22
+ # receiver
23
+ # @param [Hash] base values
24
+ # @param [Hash] other values
25
+ # @return [Hash] updated base hash
26
+ def merge!(base, other)
27
+ base.merge!(other, &method(:resolve_values))
28
+ end
29
+
30
+ # @param [Object] _key
31
+ # @param [Object] base
32
+ # @param [Object] other
33
+ # @return [Object] resolved value
34
+ def resolve_values(_key, base, other)
35
+ if base.nil? && other.nil?
36
+ nil
37
+ elsif to_hash?(base, other)
38
+ merge(base, other)
39
+ elsif to_set?(base, other)
40
+ Set.new(base) + Set.new(other)
41
+ else
42
+ other
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def to_hash?(base, other)
49
+ base.is_a?(Hash) && other.is_a?(Hash)
50
+ end
51
+
52
+ def to_set?(base, other)
53
+ (base.is_a?(Set) && set_like?(other)) || (other.is_a?(Set) && set_like?(base))
54
+ end
55
+
56
+ # rubocop: disable Naming/AccessorMethodName
57
+ def set_like?(value)
58
+ value.is_a?(Enumerable) || value.nil?
59
+ end
60
+ # rubocop: enable Naming/AccessorMethodName
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,40 @@
1
+ require 'psych'
2
+ require 'set'
3
+
4
+ module Jac
5
+ module Parser
6
+ # Cutstom Yaml AST visitor
7
+ # @see {Psych::Visitors::ToRuby}
8
+ # While standart Psych visitor converts sets to `{ value => nil }` mappings
9
+ # we need explicitly convert those mappings to ruby {Set}
10
+ class VisitorToRuby < Psych::Visitors::ToRuby
11
+ # rubocop: disable Naming/MethodName
12
+
13
+ # Uses standard Psych visitor to convert mapping to ruby object
14
+ # except `!set` case. Here we convert mapping to {Set}.
15
+ # @param [Psych::Nodes::Mapping] o YAML AST node
16
+ # @return [Object] parsed ruby object
17
+ def visit_Psych_Nodes_Mapping(o)
18
+ case o.tag
19
+ when '!set', 'tag:yaml.org,2002:set'
20
+ visit_set(o)
21
+ else # fallback to default implementation
22
+ super(o)
23
+ end
24
+ end
25
+ # rubocop: enable Naming/MethodName
26
+
27
+ private
28
+
29
+ def visit_set(o)
30
+ set = Set.new
31
+ # Update anchor
32
+ @st[o.anchor] = set if o.anchor
33
+ o.children.each_slice(2) do |k, _v|
34
+ set << accept(k)
35
+ end
36
+ set
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,14 @@
1
+ module Jac
2
+ # Jac version module
3
+ module Version
4
+ class << self
5
+ def value
6
+ [ 0, 0, 1]
7
+ end
8
+
9
+ def to_s
10
+ value.join('.')
11
+ end
12
+ end
13
+ end
14
+ end
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jac
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - ilya.arkhanhelsky
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-11-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: powerpack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '='
18
+ - !ruby/object:Gem::Version
19
+ version: 0.1.1
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '='
25
+ - !ruby/object:Gem::Version
26
+ version: 0.1.1
27
+ description: Profile based configuration lib
28
+ email: ilya.arkhanhelsky@vizor-games.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - lib/jac.rb
34
+ - lib/jac/configuration.rb
35
+ - lib/jac/merger.rb
36
+ - lib/jac/parser.rb
37
+ - lib/version.rb
38
+ homepage: https://github.com/vizor-games/jac
39
+ licenses:
40
+ - MIT
41
+ metadata: {}
42
+ post_install_message:
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ requirements: []
57
+ rubyforge_project:
58
+ rubygems_version: 2.6.13
59
+ signing_key:
60
+ specification_version: 4
61
+ summary: Just Another Configuration Lib
62
+ test_files: []