dry-configurable 0.11.6 → 1.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.
@@ -1,11 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'concurrent/map'
4
-
5
- require 'dry/equalizer'
6
-
7
- require 'dry/configurable/constants'
8
- require 'dry/configurable/errors'
3
+ require "set"
9
4
 
10
5
  module Dry
11
6
  module Configurable
@@ -19,12 +14,29 @@ module Dry
19
14
  attr_reader :_settings
20
15
 
21
16
  # @api private
22
- attr_reader :_resolved
17
+ attr_reader :_values
18
+
19
+ # @api private
20
+ attr_reader :_configured
21
+ protected :_configured
23
22
 
24
23
  # @api private
25
- def initialize(settings)
26
- @_settings = settings.dup
27
- @_resolved = Concurrent::Map.new
24
+ def initialize(settings, values: {})
25
+ @_settings = settings
26
+ @_values = values
27
+ @_configured = Set.new
28
+ end
29
+
30
+ # @api private
31
+ private def initialize_copy(source)
32
+ super
33
+ @_values = source.__send__(:dup_values)
34
+ @_configured = source._configured.dup
35
+ end
36
+
37
+ # @api private
38
+ def dup_for_settings(settings)
39
+ dup.tap { |config| config.instance_variable_set(:@_settings, settings) }
28
40
  end
29
41
 
30
42
  # Get config value by a key
@@ -34,9 +46,19 @@ module Dry
34
46
  # @return Config value
35
47
  def [](name)
36
48
  name = name.to_sym
37
- raise ArgumentError, "+#{name}+ is not a setting name" unless _settings.key?(name)
38
49
 
39
- _settings[name].value
50
+ unless (setting = _settings[name])
51
+ raise ArgumentError, "+#{name}+ is not a setting name"
52
+ end
53
+
54
+ _values.fetch(name) {
55
+ # Mutable settings may be configured after read
56
+ _configured.add(name) if setting.cloneable?
57
+
58
+ setting.to_value.tap { |value|
59
+ _values[name] = value
60
+ }
61
+ }
40
62
  end
41
63
 
42
64
  # Set config value.
@@ -45,21 +67,34 @@ module Dry
45
67
  # @param [String,Symbol] name
46
68
  # @param [Object] value
47
69
  def []=(name, value)
48
- public_send(:"#{name}=", value)
70
+ raise FrozenConfigError, "Cannot modify frozen config" if frozen?
71
+
72
+ name = name.to_sym
73
+
74
+ unless (setting = _settings[name])
75
+ raise ArgumentError, "+#{name}+ is not a setting name"
76
+ end
77
+
78
+ _configured.add(name)
79
+
80
+ _values[name] = setting.constructor.(value)
49
81
  end
50
82
 
51
83
  # Update config with new values
52
84
  #
53
- # @param values [Hash] A hash with new values
85
+ # @param values [Hash, #to_hash] A hash with new values
54
86
  #
55
87
  # @return [Config]
56
88
  #
57
89
  # @api public
58
90
  def update(values)
59
91
  values.each do |key, value|
60
- case value
61
- when Hash
62
- self[key].update(value)
92
+ if self[key].is_a?(self.class)
93
+ unless value.respond_to?(:to_hash)
94
+ raise ArgumentError, "#{value.inspect} is not a valid setting value"
95
+ end
96
+
97
+ self[key].update(value.to_hash)
63
98
  else
64
99
  self[key] = value
65
100
  end
@@ -67,62 +102,113 @@ module Dry
67
102
  self
68
103
  end
69
104
 
70
- # Dump config into a hash
105
+ # Returns true if the value for the given key has been set on this config.
106
+ #
107
+ # For simple values, this returns true if the value has been explicitly assigned.
108
+ #
109
+ # For cloneable (mutable) values, since these are captured on read, returns true if the value
110
+ # does not compare equally to its corresdponing default value. This relies on these objects
111
+ # having functioning `#==` checks.
112
+ #
113
+ # @return [Bool]
114
+ #
115
+ # @api public
116
+ def configured?(key)
117
+ if _configured.include?(key) && _settings[key].cloneable?
118
+ return _values[key] != _settings[key].to_value
119
+ end
120
+
121
+ _configured.include?(key)
122
+ end
123
+
124
+ # Returns the current config values.
125
+ #
126
+ # Nested configs remain in their {Config} instances.
71
127
  #
72
128
  # @return [Hash]
73
129
  #
74
130
  # @api public
75
131
  def values
76
- _settings
77
- .map { |setting| [setting.name, setting.value] }
78
- .map { |key, value| [key, value.is_a?(self.class) ? value.to_h : value] }
79
- .to_h
132
+ # Ensure all settings are represented in values
133
+ _settings.each { |setting| self[setting.name] unless _values.key?(setting.name) }
134
+
135
+ _values
80
136
  end
81
- alias_method :to_h, :values
82
- alias_method :to_hash, :values
83
137
 
84
- # @api private
85
- def finalize!
86
- _settings.freeze
87
- freeze
138
+ # Returns config values as a hash, with nested values also converted from {Config} instances
139
+ # into hashes.
140
+ #
141
+ # @return [Hash]
142
+ #
143
+ # @api public
144
+ def to_h
145
+ values.to_h { |key, value| [key, value.is_a?(self.class) ? value.to_h : value] }
88
146
  end
89
147
 
90
148
  # @api private
91
- def pristine
92
- self.class.new(_settings.pristine)
149
+ alias_method :_dry_equalizer_hash, :hash
150
+
151
+ # @api public
152
+ def hash
153
+ return @__hash__ if instance_variable_defined?(:@__hash__)
154
+
155
+ _dry_equalizer_hash
156
+ end
157
+
158
+ # @api public
159
+ def finalize!(freeze_values: false)
160
+ return self if frozen?
161
+
162
+ values.each_value do |value|
163
+ if value.is_a?(self.class)
164
+ value.finalize!(freeze_values: freeze_values)
165
+ elsif freeze_values
166
+ value.freeze
167
+ end
168
+ end
169
+
170
+ # Memoize the hash for the object when finalizing (regardless of whether values themselves
171
+ # are to be frozen; the intention of finalization is that no further changes should be
172
+ # made). The benefit of freezing the hash at this point is that it saves repeated expensive
173
+ # computation (through Dry::Equalizer's hash implementation) if that hash is to be used
174
+ # later in performance-sensitive situations, such as when serving as a cache key or similar.
175
+ @__hash__ = _dry_equalizer_hash
176
+
177
+ freeze
93
178
  end
94
179
 
95
180
  # @api private
96
- def respond_to_missing?(meth, include_private = false)
97
- super || _settings.key?(resolve(meth))
181
+ def pristine
182
+ self.class.new(_settings)
98
183
  end
99
184
 
100
185
  private
101
186
 
102
- # @api private
103
- def method_missing(meth, *args)
104
- setting = _settings[resolve(meth)]
187
+ def method_missing(name, *args)
188
+ setting_name = setting_name_from_method(name)
189
+ setting = _settings[setting_name]
105
190
 
106
191
  super unless setting
107
192
 
108
- if setting.writer?(meth)
109
- raise FrozenConfig, 'Cannot modify frozen config' if frozen?
110
-
111
- _settings << setting.with(input: args[0])
193
+ if name.end_with?("=")
194
+ self[setting_name] = args[0]
112
195
  else
113
- setting.value
196
+ self[setting_name]
114
197
  end
115
198
  end
116
199
 
117
- # @api private
118
- def resolve(meth)
119
- _resolved.fetch(meth) { _resolved[meth] = meth.to_s.tr('=', '').to_sym }
200
+ def respond_to_missing?(meth, include_private = false)
201
+ _settings.key?(setting_name_from_method(meth)) || super
120
202
  end
121
203
 
122
- # @api private
123
- def initialize_copy(source)
124
- super
125
- @_settings = source._settings.dup
204
+ def setting_name_from_method(method_name)
205
+ method_name.to_s.tr("=", "").to_sym
206
+ end
207
+
208
+ def dup_values
209
+ _values.each_with_object({}) { |(key, val), dup_hsh|
210
+ dup_hsh[key] = _settings[key].cloneable? ? val.dup : val
211
+ }
126
212
  end
127
213
  end
128
214
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/core/constants'
4
-
5
3
  module Dry
6
4
  # Shared constants
7
5
  #
@@ -1,11 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/configurable/constants'
4
- require 'dry/configurable/setting'
5
- require 'dry/configurable/settings'
6
- require 'dry/configurable/compiler'
7
- require 'dry/configurable/dsl/args'
8
-
9
3
  module Dry
10
4
  module Configurable
11
5
  # Setting DSL used by the class API
@@ -14,49 +8,67 @@ module Dry
14
8
  class DSL
15
9
  VALID_NAME = /\A[a-z_]\w*\z/i.freeze
16
10
 
17
- # @api private
18
11
  attr_reader :compiler
19
12
 
20
- # @api private
21
13
  attr_reader :ast
22
14
 
23
- # @api private
24
- def initialize(&block)
15
+ attr_reader :options
16
+
17
+ def initialize(**options, &block)
25
18
  @compiler = Compiler.new
26
19
  @ast = []
20
+ @options = options
27
21
  instance_exec(&block) if block
28
22
  end
29
23
 
30
- # Register a new setting node and compile it into a setting object
24
+ # Registers a new setting node and compile it into a setting object
31
25
  #
32
26
  # @see ClassMethods.setting
33
- # @api public
27
+ # @api private
34
28
  # @return Setting
35
- def setting(name, *args, &block)
29
+ def setting(name, **options, &block) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
36
30
  unless VALID_NAME.match?(name.to_s)
37
31
  raise ArgumentError, "#{name} is not a valid setting name"
38
32
  end
39
33
 
40
- args = Args.new(args)
41
-
42
- args.ensure_valid_options
34
+ ensure_valid_options(options)
43
35
 
44
- default, opts = args
36
+ options = {default: default, config_class: config_class, **options}
45
37
 
46
- node = [:setting, [name.to_sym, default, opts == default ? EMPTY_HASH : opts]]
38
+ node = [:setting, [name.to_sym, options]]
47
39
 
48
40
  if block
49
- if block.arity.zero?
50
- ast << [:nested, [node, DSL.new(&block).ast]]
51
- else
52
- ast << [:constructor, [node, block]]
53
- end
41
+ ast << [:nested, [node, DSL.new(&block).ast]]
54
42
  else
55
43
  ast << node
56
44
  end
57
45
 
58
46
  compiler.visit(ast.last)
59
47
  end
48
+
49
+ def config_class
50
+ options[:config_class]
51
+ end
52
+
53
+ def default
54
+ options[:default_undefined] ? Undefined : nil
55
+ end
56
+
57
+ private
58
+
59
+ def ensure_valid_options(options)
60
+ return if options.none?
61
+
62
+ invalid_keys = options.keys - Setting::OPTIONS
63
+
64
+ raise ArgumentError, "Invalid options: #{invalid_keys.inspect}" unless invalid_keys.empty?
65
+ end
66
+
67
+ # Returns a tuple of valid and invalid options hashes derived from the options hash
68
+ # given to the setting
69
+ def valid_and_invalid_options(options)
70
+ options.partition { |k, _| Setting::OPTIONS.include?(k) }.map(&:to_h)
71
+ end
60
72
  end
61
73
  end
62
74
  end
@@ -6,7 +6,8 @@ module Dry
6
6
  # @api public
7
7
  module Configurable
8
8
  Error = Class.new(::StandardError)
9
- AlreadyIncluded = ::Class.new(Error)
10
- FrozenConfig = ::Class.new(Error)
9
+
10
+ AlreadyIncludedError = Class.new(Error)
11
+ FrozenConfigError = Class.new(Error)
11
12
  end
12
13
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Configurable
5
+ class Extension < Module
6
+ # @api private
7
+ attr_reader :config_class
8
+
9
+ # @api private
10
+ attr_reader :default_undefined
11
+
12
+ # @api private
13
+ def initialize(config_class: Configurable::Config, default_undefined: false)
14
+ super()
15
+ @config_class = config_class
16
+ @default_undefined = default_undefined
17
+ freeze
18
+ end
19
+
20
+ # @api private
21
+ def extended(klass)
22
+ super
23
+ klass.extend(ClassMethods)
24
+ klass.instance_variable_set(:@__config_extension__, self)
25
+ end
26
+
27
+ # @api private
28
+ def included(klass)
29
+ raise AlreadyIncludedError if klass.include?(InstanceMethods)
30
+
31
+ super
32
+
33
+ klass.class_eval do
34
+ extend(ClassMethods)
35
+ include(InstanceMethods)
36
+ prepend(Initializer)
37
+
38
+ class << self
39
+ undef :config
40
+ undef :configure
41
+ end
42
+ end
43
+
44
+ klass.instance_variable_set(:@__config_extension__, self)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,10 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/configurable/config'
4
- require 'dry/configurable/methods'
3
+ require "dry/configurable/config"
4
+ require "dry/configurable/methods"
5
5
 
6
6
  module Dry
7
7
  module Configurable
8
+ # Initializer method which is prepended when `Dry::Configurable`
9
+ # is included in a class
10
+ #
11
+ # @api private
12
+ module Initializer
13
+ # @api private
14
+ def initialize(*)
15
+ @__config__ = self.class.__config_build__(self.class.settings)
16
+
17
+ super
18
+ end
19
+ ruby2_keywords(:initialize) if respond_to?(:ruby2_keywords, true)
20
+ end
21
+
8
22
  # Instance-level API when `Dry::Configurable` is included in a class
9
23
  #
10
24
  # @api public
@@ -16,20 +30,14 @@ module Dry
16
30
  # @return [Config]
17
31
  #
18
32
  # @api public
19
- attr_reader :config
20
-
21
- # @api private
22
- def initialize(*)
23
- @config = Config.new(self.class._settings.dup)
24
- super
33
+ def config
34
+ @__config__
25
35
  end
26
36
 
27
37
  # Finalize the config and freeze the object
28
38
  #
29
39
  # @api public
30
- def finalize!
31
- return self if frozen?
32
-
40
+ def finalize!(freeze_values: false)
33
41
  super
34
42
  freeze
35
43
  end
@@ -39,7 +47,7 @@ module Dry
39
47
  # @api public
40
48
  def initialize_copy(source)
41
49
  super
42
- @config = source.config.dup
50
+ @__config__ = source.config.dup
43
51
  end
44
52
  end
45
53
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/configurable/errors'
4
-
5
3
  module Dry
6
4
  module Configurable
7
5
  # Common API for both classes and instances
@@ -10,7 +8,7 @@ module Dry
10
8
  module Methods
11
9
  # @api public
12
10
  def configure(&block)
13
- raise FrozenConfig, 'Cannot modify frozen config' if frozen?
11
+ raise FrozenConfigError, "Cannot modify frozen config" if config.frozen?
14
12
 
15
13
  yield(config) if block
16
14
  self
@@ -21,10 +19,8 @@ module Dry
21
19
  # @return [Dry::Configurable::Config]
22
20
  #
23
21
  # @api public
24
- def finalize!
25
- return self if config.frozen?
26
-
27
- config.finalize!
22
+ def finalize!(freeze_values: false)
23
+ config.finalize!(freeze_values: freeze_values)
28
24
  self
29
25
  end
30
26
  end
@@ -1,102 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
-
5
- require 'dry/equalizer'
6
-
7
- require 'dry/configurable/constants'
8
- require 'dry/configurable/config'
3
+ require "set"
9
4
 
10
5
  module Dry
11
6
  module Configurable
12
- # This class represents a setting and is used internally.
7
+ # A defined setting.
13
8
  #
14
- # @api private
9
+ # @api public
15
10
  class Setting
16
- include Dry::Equalizer(:name, :value, :options, inspect: false)
11
+ include Dry::Equalizer(:name, :default, :constructor, :children, :options, inspect: false)
17
12
 
18
- OPTIONS = %i[input default reader constructor settings].freeze
13
+ OPTIONS = %i[default reader constructor mutable cloneable settings config_class].freeze
19
14
 
20
15
  DEFAULT_CONSTRUCTOR = -> v { v }.freeze
21
16
 
22
- CLONABLE_VALUE_TYPES = [Array, Hash, Set, Config].freeze
17
+ MUTABLE_VALUE_TYPES = [Array, Hash, Set, Config].freeze
23
18
 
24
- # @api private
19
+ # @api public
25
20
  attr_reader :name
26
21
 
27
- # @api private
28
- attr_reader :writer_name
22
+ # @api public
23
+ attr_reader :default
29
24
 
30
- # @api private
31
- attr_reader :input
25
+ # @api public
26
+ attr_reader :mutable
32
27
 
33
- # @api private
34
- attr_reader :default
28
+ # @api public
29
+ attr_reader :constructor
35
30
 
36
- # @api private
31
+ # @api public
32
+ attr_reader :children
33
+
34
+ # @api public
37
35
  attr_reader :options
38
36
 
39
- # Specialized Setting which includes nested settings
40
- #
41
37
  # @api private
42
- class Nested < Setting
43
- CONSTRUCTOR = Config.method(:new)
44
-
45
- # @api private
46
- def pristine
47
- with(input: input.pristine)
48
- end
49
-
50
- # @api private
51
- def constructor
52
- CONSTRUCTOR
53
- end
38
+ def self.mutable_value?(value)
39
+ MUTABLE_VALUE_TYPES.any? { |type| value.is_a?(type) }
54
40
  end
55
41
 
56
42
  # @api private
57
- def initialize(name, input: Undefined, default: Undefined, **options)
43
+ def initialize(
44
+ name,
45
+ default:,
46
+ constructor: DEFAULT_CONSTRUCTOR,
47
+ children: EMPTY_ARRAY,
48
+ **options
49
+ )
58
50
  @name = name
59
- @writer_name = :"#{name}="
60
- @input = input.equal?(Undefined) ? default : input
61
51
  @default = default
52
+ @mutable = children.any? || options.fetch(:mutable) {
53
+ # Allow `cloneable` as an option alias for `mutable`
54
+ options.fetch(:cloneable) { Setting.mutable_value?(default) }
55
+ }
56
+ @constructor = constructor
57
+ @children = children
62
58
  @options = options
63
-
64
- evaluate if input_defined?
65
- end
66
-
67
- # @api private
68
- def input_defined?
69
- !input.equal?(Undefined)
70
- end
71
-
72
- # @api private
73
- def value
74
- @value ||= evaluate
75
- end
76
-
77
- # @api private
78
- def evaluated?
79
- instance_variable_defined?(:@value)
80
- end
81
-
82
- # @api private
83
- def nested(settings)
84
- Nested.new(name, input: settings, **options)
85
- end
86
-
87
- # @api private
88
- def pristine
89
- with(input: Undefined)
90
- end
91
-
92
- # @api private
93
- def with(new_opts)
94
- self.class.new(name, input: input, default: default, **options, **new_opts)
95
- end
96
-
97
- # @api private
98
- def constructor
99
- options[:constructor] || DEFAULT_CONSTRUCTOR
100
59
  end
101
60
 
102
61
  # @api private
@@ -104,28 +63,22 @@ module Dry
104
63
  options[:reader].equal?(true)
105
64
  end
106
65
 
107
- # @api private
108
- def writer?(meth)
109
- writer_name.equal?(meth)
66
+ # @api public
67
+ def mutable?
68
+ mutable
110
69
  end
70
+ alias_method :cloneable?, :mutable?
111
71
 
112
72
  # @api private
113
- def clonable_value?
114
- CLONABLE_VALUE_TYPES.any? { |type| value.is_a?(type) }
115
- end
116
-
117
- private
73
+ def to_value
74
+ if children.any?
75
+ (options[:config_class] || Config).new(children)
76
+ else
77
+ value = default
78
+ value = constructor.(value) unless value.eql?(Undefined)
118
79
 
119
- # @api private
120
- def initialize_copy(source)
121
- super
122
- @value = source.value.dup if source.input_defined? && source.clonable_value?
123
- @options = source.options.dup
124
- end
125
-
126
- # @api private
127
- def evaluate
128
- @value = constructor[input.equal?(Undefined) ? nil : input]
80
+ mutable? ? value.dup : value
81
+ end
129
82
  end
130
83
  end
131
84
  end