basic_settings 0.0.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8998448872e60b7a9e71aeedecbb193441c9f701d39091596612dfd03456b019
4
- data.tar.gz: f000c6906032862a8adb1ee477403c7f4b10e6aa916a666774f5905af14bb2c9
3
+ metadata.gz: eb4126f08d2f2e7828721ca48a8b4cf209bc7ab61984472d56b171e59cae8815
4
+ data.tar.gz: '0698f0a8e0f59ff75410f084ada9894a2f2d9a6d1e878d3a4a83fcddffb7b91a'
5
5
  SHA512:
6
- metadata.gz: f298848415f744a288e2e27aa5824e275f49dedf8d70eb673afada0c2d28a0513c4f453c431f02de8c06430a2bdb4c48d652811b0373aa53f241544966873c7f
7
- data.tar.gz: d6150027cfcd448546a1dcf8de85ef04181fed4c2e3e1bdf1511ed9d50d882b227491e111d99dd22e930ae7f982a03e6df206d3ddf233a734dd76fc440e930e6
6
+ metadata.gz: 7a2c8c15fafc87cf0ebc06f8717d9a1e9340d5cda7693b48ac36deac1ad71f64e366b0b93a58645e2666ef559941e2207a6230d1e4627788a0626c5ddf45cac6
7
+ data.tar.gz: 1a80160b8dfdf334e0b727ce5cf037b48d23e9473436906c1de944945b29efaf6770b11795b374f7b43ff9be20d79ac43113d6c4cb2d924bec80b9f7ec45bd20
data/.yardopts ADDED
@@ -0,0 +1,11 @@
1
+ --no-private
2
+ --title=BasicSettings
3
+ --markup=markdown
4
+ --markup-provider redcarpet
5
+ --main=README.md
6
+ ./lib/basic_settings/**/*.rb
7
+ ./lib/basic_settings.rb
8
+ -
9
+ README.md
10
+ LICENSE.md
11
+ CHANGELOG.md
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Releases
2
+
3
+ ### v0.1.0 / 2026-04-14
4
+
5
+ * ADDED: Initial extraction of basic_settings from toys-core
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ # License
2
+
3
+ Copyright 2026 Daniel Azuma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21
+ IN THE SOFTWARE.
data/README.md CHANGED
@@ -1,9 +1,45 @@
1
- # Placeholder for basic_settings
1
+ # BasicSettings
2
2
 
3
- This is a placeholder gem, which was generated on 2026-04-13 to
4
- reserve the gem "basic_settings".
5
- The actual gem is planned for release in the near future.
3
+ The basic_settings gem provides a way to define settings objects that define
4
+ define the structure of application settings, i.e. the various fields that can
5
+ be set, and their types. It provides type checking, defaults inheritance, and
6
+ and hierarchical structure.
6
7
 
7
- If this is a problem, or if the actual gem has not been released
8
- in a timely manner, you can contact the owner at
9
- `dazuma@gmail.com`.
8
+ This class was originally part of the `toys-core` gem, but was extracted when
9
+ it was determined that it was not needed there.
10
+
11
+ ## Contributing
12
+
13
+ Development is done in GitHub at https://github.com/dazuma/basic_settings.
14
+
15
+ * To file issues: https://github.com/dazuma/basic_settings/issues.
16
+ * For questions and discussion, please do not file an issue. Instead, use the
17
+ discussions feature: https://github.com/dazuma/basic_settings/discussions.
18
+ * Pull requests are welcome, but I recommend first filing an issue, and/or
19
+ discussing substantial changes in GitHub discussions, before implementing.
20
+
21
+ The library uses [toys](https://dazuma.github.io/toys) for testing and CI. To
22
+ run the test suite, `gem install toys` and then run `toys ci`. You can also run
23
+ unit tests, rubocop, and build tests independently.
24
+
25
+ ## License
26
+
27
+ Copyright 2026 Daniel Azuma
28
+
29
+ Permission is hereby granted, free of charge, to any person obtaining a copy
30
+ of this software and associated documentation files (the "Software"), to deal
31
+ in the Software without restriction, including without limitation the rights
32
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
33
+ copies of the Software, and to permit persons to whom the Software is
34
+ furnished to do so, subject to the following conditions:
35
+
36
+ The above copyright notice and this permission notice shall be included in
37
+ all copies or substantial portions of the Software.
38
+
39
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
40
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
41
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
42
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
43
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
44
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
45
+ IN THE SOFTWARE.
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BasicSettings
4
+ ##
5
+ # Current version of basic_settings
6
+ # @return [String]
7
+ #
8
+ VERSION = "0.1.0"
9
+ end
@@ -1,8 +1,942 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # A BasicSettings class defines the structure of application settings, i.e. the
5
+ # various fields that can be set, and their types. You can define a settings
6
+ # structure by subclassing this base class, and using the provided methods.
1
7
  #
2
- # This is a placeholder Ruby file for gem "basic_settings".
3
- # It was generated on 2026-04-13 to reserve the gem name.
4
- # The actual gem is planned for release in the near future.
5
- # If this is a problem, or if the actual gem has not been
6
- # released in a timely manner, you can contact the owner at
7
- # dazuma@gmail.com
8
+ # ### Attributes
8
9
  #
10
+ # To define an attribute, use the {BasicSettings.settings_attr} declaration.
11
+ #
12
+ # Example:
13
+ #
14
+ # class ServiceSettings < BasicSettings
15
+ # settings_attr :endpoint, default: "api.example.com"
16
+ # end
17
+ #
18
+ # my_settings = ServiceSettings.new
19
+ # my_settings.endpoint_set? # => false
20
+ # my_settings.endpoint # => "api.example.com"
21
+ # my_settings.endpoint = "rest.example.com"
22
+ # my_settings.endpoint_set? # => true
23
+ # my_settings.endpoint # => "rest.example.com"
24
+ # my_settings.endpoint_unset!
25
+ # my_settings.endpoint_set? # => false
26
+ # my_settings.endpoint # => "api.example.com"
27
+ #
28
+ # An attribute has a name, a default value, and a type specification. The
29
+ # name is used to define methods for getting and setting the attribute. The
30
+ # default is returned if no value is set. (See the section below on parents
31
+ # and defaults for more information.) The type specification governs what
32
+ # values are allowed. (See the section below on type specifications.)
33
+ #
34
+ # Attribute names must start with an ascii letter, and may contain only ascii
35
+ # letters, digits, and underscores. Unlike method names, they may not include
36
+ # non-ascii unicode characters, nor may they end with `!` or `?`.
37
+ # Additionally, some names are reserved because they would shadow critical
38
+ # Ruby methods or interfere with BasicSettings' internal behavior (see
39
+ # {BasicSettings::RESERVED_FIELD_NAMES} for the complete list, which
40
+ # currently includes names such as `class`, `clone`, `dup`, `freeze`, `hash`,
41
+ # `initialize`, `method_missing`, `object_id`, `public_send`, `raise`,
42
+ # `require`, and `send`).
43
+ #
44
+ # Each attribute defines four methods: a getter, a setter, an unsetter, and a
45
+ # set detector. In the above example, the attribute named `:endpoint` creates
46
+ # the following four methods:
47
+ #
48
+ # * `endpoint` - retrieves the attribute value, or a default if not set.
49
+ # * `endpoint=(value)` - sets a new attribute value.
50
+ # * `endpoint_unset!` - unsets the attribute, reverting to a default.
51
+ # * `endpoint_set?` - returns a boolean, whether the attribute is set.
52
+ #
53
+ # ### Groups
54
+ #
55
+ # A group is a settings field that itself is a BasicSettings object. You can
56
+ # use it to group settings fields in a hierarchy.
57
+ #
58
+ # Example:
59
+ #
60
+ # class ServiceSettings < BasicSettings
61
+ # settings_attr :endpoint, default: "api.example.com"
62
+ # settings_group :service_flags do
63
+ # settings_attr :verbose, default: false
64
+ # settings_attr :use_proxy, default: false
65
+ # end
66
+ # end
67
+ #
68
+ # my_settings = ServiceSettings.new
69
+ # my_settings.service_flags.verbose # => false
70
+ # my_settings.service_flags.verbose = true
71
+ # my_settings.service_flags.verbose # => true
72
+ # my_settings.endpoint # => "api.example.com"
73
+ #
74
+ # You can define a group inline, as in the example above, or create an
75
+ # explicit settings class and use it for the group. For example:
76
+ #
77
+ # class Flags < BasicSettings
78
+ # settings_attr :verbose, default: false
79
+ # settings_attr :use_proxy, default: false
80
+ # end
81
+ # class ServiceSettings < BasicSettings
82
+ # settings_attr :endpoint, default: "api.example.com"
83
+ # settings_group :service_flags, Flags
84
+ # end
85
+ #
86
+ # my_settings = ServiceSettings.new
87
+ # my_settings.service_flags.verbose = true
88
+ #
89
+ # If the module enclosing a subclass of `BasicSettings` is itself a subclass of
90
+ # `BasicSettings`, then the class is automatically added to its enclosing class
91
+ # as a group. For example:
92
+ #
93
+ # class ServiceSettings < BasicSettings
94
+ # settings_attr :endpoint, default: "api.example.com"
95
+ # # Automatically adds this as the group service_flags.
96
+ # # The name is inferred (snake_cased) from the class name.
97
+ # class ServiceFlags < BasicSettings
98
+ # settings_attr :verbose, default: false
99
+ # settings_attr :use_proxy, default: false
100
+ # end
101
+ # end
102
+ #
103
+ # my_settings = ServiceSettings.new
104
+ # my_settings.service_flags.verbose = true
105
+ #
106
+ # ### Type specifications
107
+ #
108
+ # A type specification is a restriction on the types of values allowed for a
109
+ # settings field. Every attribute has a type specification. You can set it
110
+ # explicitly by providing a `:type` argument or a block. If a type
111
+ # specification is not provided explicitly, it is inferred from the default
112
+ # value of the attribute.
113
+ #
114
+ # Type specifications can be any of the following:
115
+ #
116
+ # * A Module, restricting values to those that include the module.
117
+ #
118
+ # For example, a type specification of `Enumerable` would accept `[123]`
119
+ # but not `123`.
120
+ #
121
+ # * A Class, restricting values to that class or any subclass.
122
+ #
123
+ # For example, a type specification of `Time` would accept `Time.now` but
124
+ # not `DateTime.now`.
125
+ #
126
+ # Note that some classes will convert (i.e. parse) strings. For example,
127
+ # a type specification of `Integer` will accept the string `"-123"`` and
128
+ # convert it to the value `-123`. Classes that support parsing include:
129
+ #
130
+ # * `Date`
131
+ # * `DateTime`
132
+ # * `Float`
133
+ # * `Integer`
134
+ # * `Regexp`
135
+ # * `Symbol`
136
+ # * `Time`
137
+ #
138
+ # * A Regexp, restricting values to strings matching the regexp.
139
+ #
140
+ # For example, a type specification of `/^\w+$/` would match `"abc"` but
141
+ # not `"abc!"`.
142
+ #
143
+ # * A Range, restricting values to objects that fall in the range and are
144
+ # of the same class (or a subclass) as the endpoints. String values are
145
+ # accepted if they can be converted to the endpoint class as specified by
146
+ # a class type specification.
147
+ #
148
+ # For example, a type specification of `(1..5)` would match `5` but not
149
+ # `6`. It would also match `"5"` because the String can be parsed into an
150
+ # Integer in the range.
151
+ #
152
+ # * A specific value, any Symbol, String, Numeric, or the values `nil`,
153
+ # `true`, or `false`, restricting the value to only that given value.
154
+ #
155
+ # For example, a type specification of `:foo` would match `:foo` but not
156
+ # `:bar`.
157
+ #
158
+ # (It might not seem terribly useful to have an attribute that can take
159
+ # only one value, but this type is generally used as part of a union
160
+ # type, described below, to implement an enumeration.)
161
+ #
162
+ # * An Array representing a union type, each of whose elements is one of
163
+ # the above types. Values are accepted if they match any of the elements.
164
+ #
165
+ # For example, a type specification of `[:a, :b :c]` would match `:a` but
166
+ # not `"a"`. Similarly, a type specification of `[String, Integer, nil]`
167
+ # would match `"hello"`, `123`, or `nil`, but not `123.4`.
168
+ #
169
+ # * A Proc that takes the proposed value and returns either the value if it
170
+ # is legal, the converted value if it can be converted to a legal value,
171
+ # or the constant {BasicSettings::ILLEGAL_VALUE} if it cannot be
172
+ # converted to a legal value. You may also pass a block to
173
+ # `settings_attr` to set a Proc type specification.
174
+ #
175
+ # * A {BasicSettings::Type} that checks and converts values.
176
+ #
177
+ # If you do not explicitly provide a type specification, one is inferred from
178
+ # the attribute's default value. The rules are:
179
+ #
180
+ # * If the default value is `true` or `false`, then the type specification
181
+ # inferred is `[true, false]`.
182
+ #
183
+ # * If the default value is `nil` or not provided, then the type
184
+ # specification allows any object (i.e. is equivalent to `Object`).
185
+ #
186
+ # * Otherwise, the type specification allows any value of the same class as
187
+ # the default value. For example, if the default value is `""`, the
188
+ # effective type specification is `String`.
189
+ #
190
+ # Examples:
191
+ #
192
+ # class ServiceSettings < BasicSettings
193
+ # # Allows only strings because the default is a string.
194
+ # settings_attr :endpoint, default: "example.com"
195
+ # end
196
+ #
197
+ # class ServiceSettings < BasicSettings
198
+ # # Allows strings or nil.
199
+ # settings_attr :endpoint, default: "example.com", type: [String, nil]
200
+ # end
201
+ #
202
+ # class ServiceSettings < BasicSettings
203
+ # # Raises ArgumentError because the default is nil, which does not
204
+ # # match the type specification. (You should either allow nil
205
+ # # explicitly with `type: [String, nil]` or set the default to a
206
+ # # suitable string such as the empty string "".)
207
+ # settings_attr :endpoint, type: String
208
+ # end
209
+ #
210
+ # ### Settings parents
211
+ #
212
+ # A settings object can have a "parent" which provides the values if they are
213
+ # not set in the settings object. This lets you organize settings as
214
+ # "defaults" and "overrides". A parent settings object provides the defaults,
215
+ # and a child can selectively override certain values.
216
+ #
217
+ # To set the parent for a settings object, pass it as the argument to the
218
+ # BasicSettings constructor. When a field in a settings object is queried, it
219
+ # looks up the value as follows:
220
+ #
221
+ # * If a field value is explicitly set in the settings object, that value
222
+ # is returned.
223
+ # * If the field is not set in the settings object, but the settings object
224
+ # has a parent, the parent is queried. If that parent also does not have
225
+ # a value for the field, it may query its parent in turn, and so forth.
226
+ # * If we encounter a root settings with no parent, and still no value is
227
+ # set for the field, the default for the *original* setting is returned.
228
+ #
229
+ # Example:
230
+ #
231
+ # class MySettings < BasicSettings
232
+ # settings_attr :str, default: "default"
233
+ # end
234
+ #
235
+ # root_settings = MySettings.new
236
+ # child_settings = MySettings.new(root_settings)
237
+ # child_settings.str # => "default"
238
+ # root_settings.str = "value_from_root"
239
+ # child_settings.str # => "value_from_root"
240
+ # child_settings.str = "value_from_child"
241
+ # child_settings.str # => "value_from_child"
242
+ # child_settings.str_unset!
243
+ # child_settings.str # => "value_from_root"
244
+ # root_settings.str_unset!
245
+ # child_settings.str # => "default"
246
+ #
247
+ # Parents are honored through groups as well. For example:
248
+ #
249
+ # class MySettings < BasicSettings
250
+ # settings_group :flags do
251
+ # settings_attr :verbose, default: false
252
+ # settings_attr :force, default: false
253
+ # end
254
+ # end
255
+ #
256
+ # root_settings = MySettings.new
257
+ # child_settings = MySettings.new(root_settings)
258
+ # child_settings.flags.verbose # => false
259
+ # root_settings.flags.verbose = true
260
+ # child_settings.flags.verbose # => true
261
+ #
262
+ # Usually, a settings and its parent (and its parent, and so forth) should
263
+ # have the same class. This guarantees that they define the same fields with
264
+ # the same type specifications. However, this is not required. If a parent
265
+ # does not define a particular field, it is treated as if that field is
266
+ # unset, and lookup proceeds to its parent. To illustrate:
267
+ #
268
+ # class Settings1 < BasicSettings
269
+ # settings_attr :str, default: "default"
270
+ # end
271
+ # class Settings2 < BasicSettings
272
+ # end
273
+ #
274
+ # root_settings = Settings1.new
275
+ # child_settings = Settings2.new(root_settings) # does not have str
276
+ # grandchild_settings = Settings1.new(child_settings)
277
+ #
278
+ # grandchild_settings.str # => "default"
279
+ # root_settings.str = "value_from_root"
280
+ # grandchild_settings.str # => "value_from_root"
281
+ #
282
+ # Type specifications are enforced when falling back to parent values. If a
283
+ # parent provides a value that is not allowed, it is treated as if the field
284
+ # is unset, and lookup proceeds to its parent.
285
+ #
286
+ # class Settings1 < BasicSettings
287
+ # settings_attr :str, default: "default" # type spec is String
288
+ # end
289
+ # class Settings2 < BasicSettings
290
+ # settings_attr :str, default: 0 # type spec is Integer
291
+ # end
292
+ #
293
+ # root_settings = Settings1.new
294
+ # child_settings = Settings2.new(root_settings)
295
+ # grandchild_settings = Settings1.new(child_settings)
296
+ #
297
+ # grandchild_settings.str # => "default"
298
+ # child_settings.str = 123 # does not match grandchild's type
299
+ # root_settings.str = "value_from_root"
300
+ # grandchild_settings.str # => "value_from_root"
301
+ #
302
+ class BasicSettings
303
+ ##
304
+ # A special value indicating a type check failure.
305
+ #
306
+ ILLEGAL_VALUE = ::Object.new.freeze
307
+
308
+ ##
309
+ # A special type specification indicating infer from the default value.
310
+ #
311
+ DEFAULT_TYPE = ::Object.new.freeze
312
+
313
+ ##
314
+ # Field names that are not allowed because they would shadow critical Ruby
315
+ # methods or break BasicSettings' own internal method calls.
316
+ #
317
+ # @return [Array<String>]
318
+ #
319
+ RESERVED_FIELD_NAMES = [
320
+ "class", "clone", "dup", "freeze", "hash", "initialize",
321
+ "method_missing", "object_id", "public_send", "raise", "require", "send"
322
+ ].freeze
323
+
324
+ ##
325
+ # Error raised when a value does not match the type constraint.
326
+ #
327
+ class FieldError < ::StandardError
328
+ ##
329
+ # The value that did not match
330
+ # @return [Object]
331
+ #
332
+ attr_reader :value
333
+
334
+ ##
335
+ # The settings class that rejected the value
336
+ # @return [Class]
337
+ #
338
+ attr_reader :settings_class
339
+
340
+ ##
341
+ # The field that rejected the value
342
+ # @return [Symbol]
343
+ #
344
+ attr_reader :field_name
345
+
346
+ ##
347
+ # A description of the type constraint, or nil if the field didn't exist.
348
+ # @return [String, nil]
349
+ #
350
+ attr_reader :type_description
351
+
352
+ ##
353
+ # @private This interface is internal and subject to change without warning.
354
+ #
355
+ def initialize(value, settings_class, field_name, type_description)
356
+ @value = value
357
+ @settings_class = settings_class
358
+ @field_name = field_name
359
+ @type_description = type_description
360
+ message = "unable to set #{settings_class}##{field_name}"
361
+ message =
362
+ if type_description
363
+ "#{message}: value #{value.inspect} does not match type #{type_description}"
364
+ else
365
+ "#{message}: field does not exist"
366
+ end
367
+ super(message)
368
+ end
369
+ end
370
+
371
+ ##
372
+ # A type object that checks values.
373
+ #
374
+ # A Type includes a description string and a testing function. The testing
375
+ # function takes a proposed value and returns either the value itself if it
376
+ # is valid, a converted value if the value can be converted to a valid
377
+ # value, or {ILLEGAL_VALUE} if the type check failed.
378
+ #
379
+ class Type
380
+ ##
381
+ # Create a new Type.
382
+ #
383
+ # @param description [String] Name of the type.
384
+ # @param block [Proc] A testing function.
385
+ #
386
+ def initialize(description, &block)
387
+ @description = description.freeze
388
+ @tester = block
389
+ end
390
+
391
+ ##
392
+ # The name of the type.
393
+ # @return [String]
394
+ #
395
+ attr_reader :description
396
+
397
+ ##
398
+ # Test a value, possibly converting to a legal value.
399
+ #
400
+ # @param val [Object] The value to be tested.
401
+ # @return [Object] The validated value, the value converted to a legal
402
+ # value, or {ILLEGAL_VALUE} if the type check is unsuccessful.
403
+ #
404
+ def call(val)
405
+ @tester.call(val)
406
+ end
407
+
408
+ class << self
409
+ ##
410
+ # Create and return a Type given a type specification. See the
411
+ # {BasicSettings} class documentation for valid type specifications.
412
+ #
413
+ # @param type_spec [Object]
414
+ # @return [Type]
415
+ # @raise [ArgumentError] if the type specification is invalid.
416
+ #
417
+ def for_type_spec(type_spec)
418
+ case type_spec
419
+ when Type
420
+ type_spec
421
+ when ::Module
422
+ for_module(type_spec)
423
+ when ::Range
424
+ for_range(type_spec)
425
+ when ::Regexp
426
+ for_regexp(type_spec)
427
+ when ::Array
428
+ for_union(type_spec)
429
+ when ::Proc
430
+ new("(opaque proc)", &type_spec)
431
+ when nil, true, false, ::String, ::Symbol, ::Numeric
432
+ for_scalar(type_spec)
433
+ else
434
+ raise ::ArgumentError, "Illegal type spec: #{type_spec.inspect}"
435
+ end
436
+ end
437
+
438
+ ##
439
+ # Create and return a Type given a default value. See the {BasicSettings}
440
+ # class documentation for the rules.
441
+ #
442
+ # @param value [Object]
443
+ # @return [Type]
444
+ #
445
+ def for_default_value(value)
446
+ case value
447
+ when nil
448
+ for_module(::Object)
449
+ when true, false
450
+ for_union([true, false])
451
+ else
452
+ for_module(value.class)
453
+ end
454
+ end
455
+
456
+ private
457
+
458
+ def for_module(klass)
459
+ new(klass.to_s) do |val|
460
+ convert(val, klass)
461
+ end
462
+ end
463
+
464
+ def for_range(range)
465
+ range_class = (range.begin || range.end).class
466
+ new("(#{range})") do |val|
467
+ converted = convert(val, range_class)
468
+ converted != ILLEGAL_VALUE && range.member?(converted) ? converted : ILLEGAL_VALUE
469
+ end
470
+ end
471
+
472
+ def for_regexp(regexp)
473
+ regexp_str = regexp.source.gsub("/", "\\/")
474
+ new("/#{regexp_str}/") do |val|
475
+ val.is_a?(::String) && regexp.match(val) ? val : ILLEGAL_VALUE
476
+ end
477
+ end
478
+
479
+ def for_union(array)
480
+ types = array.map { |elem| for_type_spec(elem) }
481
+ descriptions = types.map(&:description).join(", ")
482
+ new("[#{descriptions}]") do |val|
483
+ result = ILLEGAL_VALUE
484
+ types.each do |type|
485
+ converted = type.call(val)
486
+ if converted.eql?(val)
487
+ result = val
488
+ break
489
+ elsif result == ILLEGAL_VALUE
490
+ result = converted
491
+ end
492
+ end
493
+ result
494
+ end
495
+ end
496
+
497
+ def for_scalar(value)
498
+ new(value.inspect) do |val|
499
+ val == value ? val : ILLEGAL_VALUE
500
+ end
501
+ end
502
+
503
+ def convert(val, klass)
504
+ return val if val.is_a?(klass)
505
+ begin
506
+ CONVERTERS[klass.name].call(val)
507
+ rescue ::StandardError
508
+ ILLEGAL_VALUE
509
+ end
510
+ end
511
+ end
512
+
513
+ date_converter = proc do |val|
514
+ case val
515
+ when ::String
516
+ ::Date.parse(val)
517
+ when ::Numeric
518
+ ::Time.at(val, in: "UTC").to_date
519
+ else
520
+ ILLEGAL_VALUE
521
+ end
522
+ end
523
+
524
+ datetime_converter = proc do |val|
525
+ case val
526
+ when ::String
527
+ ::DateTime.parse(val)
528
+ when ::Numeric
529
+ ::Time.at(val, in: "UTC").to_datetime
530
+ else
531
+ ILLEGAL_VALUE
532
+ end
533
+ end
534
+
535
+ float_converter = proc do |val|
536
+ case val
537
+ when ::String
538
+ Float(val)
539
+ when ::Numeric
540
+ converted = val.to_f
541
+ converted == val ? converted : ILLEGAL_VALUE
542
+ else
543
+ ILLEGAL_VALUE
544
+ end
545
+ end
546
+
547
+ integer_converter = proc do |val|
548
+ case val
549
+ when ::String
550
+ Integer(val)
551
+ when ::Numeric
552
+ converted = val.to_i
553
+ converted == val ? converted : ILLEGAL_VALUE
554
+ else
555
+ ILLEGAL_VALUE
556
+ end
557
+ end
558
+
559
+ regexp_converter = proc do |val|
560
+ val.is_a?(::String) ? ::Regexp.new(val) : ILLEGAL_VALUE
561
+ end
562
+
563
+ symbol_converter = proc do |val|
564
+ val.is_a?(::String) ? val.to_sym : ILLEGAL_VALUE
565
+ end
566
+
567
+ time_converter = proc do |val|
568
+ case val
569
+ when ::String
570
+ ::DateTime.parse(val).to_time
571
+ when ::Numeric
572
+ ::Time.at(val, in: "UTC")
573
+ else
574
+ ILLEGAL_VALUE
575
+ end
576
+ end
577
+
578
+ ##
579
+ # @private
580
+ #
581
+ CONVERTERS = {
582
+ "Date" => date_converter,
583
+ "DateTime" => datetime_converter,
584
+ "Float" => float_converter,
585
+ "Integer" => integer_converter,
586
+ "Regexp" => regexp_converter,
587
+ "Symbol" => symbol_converter,
588
+ "Time" => time_converter,
589
+ }.freeze
590
+ end
591
+
592
+ ##
593
+ # Create a settings instance.
594
+ #
595
+ # @param parent [BasicSettings,nil] Optional parent settings.
596
+ #
597
+ def initialize(parent: nil)
598
+ unless parent.nil? || parent.is_a?(::BasicSettings)
599
+ raise ::ArgumentError, "parent must be a BasicSettings object, if given"
600
+ end
601
+ @parent = parent
602
+ @fields = self.class.fields
603
+ @mutex = ::Mutex.new
604
+ @values = {}
605
+ end
606
+
607
+ ##
608
+ # Load the given hash of data into this settings object.
609
+ #
610
+ # @param data [Hash] The data as a hash of key-value pairs.
611
+ # @param raise_on_failure [boolean] If `true`, raises an exception on the
612
+ # first error encountered. If `false`, continues parsing and returns an
613
+ # array of the errors raised.
614
+ # @return [Array<FieldError>] An array of errors.
615
+ #
616
+ def load_data!(data, raise_on_failure: false)
617
+ errors = []
618
+ data.each do |name, value|
619
+ name = name.to_sym
620
+ field = @fields[name]
621
+ begin
622
+ raise FieldError.new(value, self.class, name, nil) unless field
623
+ if field.group?
624
+ raise FieldError.new(value, self.class, name, "Hash") unless value.is_a?(::Hash)
625
+ errors.concat(get!(field).load_data!(value, raise_on_failure: raise_on_failure))
626
+ else
627
+ set!(field, value)
628
+ end
629
+ rescue FieldError => e
630
+ raise e if raise_on_failure
631
+ errors << e
632
+ end
633
+ end
634
+ errors
635
+ end
636
+
637
+ ##
638
+ # Parse the given YAML string and load the data into this settings object.
639
+ #
640
+ # @param str [String] The YAML-formatted string.
641
+ # @param raise_on_failure [boolean] If `true`, raises an exception on the
642
+ # first error encountered. If `false`, continues parsing and returns an
643
+ # array of the errors raised.
644
+ # @return [Array<FieldError>] An array of errors.
645
+ #
646
+ def load_yaml!(str, raise_on_failure: false)
647
+ require "psych"
648
+ load_data!(::Psych.safe_load(str, permitted_classes: [::Symbol]), raise_on_failure: raise_on_failure)
649
+ end
650
+
651
+ ##
652
+ # Parse the given YAML file and load the data into this settings object.
653
+ #
654
+ # @param filename [String] The path to the YAML-formatted file.
655
+ # @param raise_on_failure [boolean] If `true`, raises an exception on the
656
+ # first error encountered. If `false`, continues parsing and returns an
657
+ # array of the errors raised.
658
+ # @return [Array<FieldError>] An array of errors.
659
+ #
660
+ def load_yaml_file!(filename, raise_on_failure: false)
661
+ load_yaml!(::File.read(filename), raise_on_failure: raise_on_failure)
662
+ end
663
+
664
+ ##
665
+ # Parse the given JSON string and load the data into this settings object.
666
+ #
667
+ # @param str [String] The JSON-formatted string.
668
+ # @param raise_on_failure [boolean] If `true`, raises an exception on the
669
+ # first error encountered. If `false`, continues parsing and returns an
670
+ # array of the errors raised.
671
+ # @return [Array<FieldError>] An array of errors.
672
+ #
673
+ def load_json!(str, raise_on_failure: false, **json_opts)
674
+ require "json"
675
+ load_data!(::JSON.parse(str, json_opts), raise_on_failure: raise_on_failure)
676
+ end
677
+
678
+ ##
679
+ # Parse the given JSON file and load the data into this settings object.
680
+ #
681
+ # @param filename [String] The path to the JSON-formatted file.
682
+ # @param raise_on_failure [boolean] If `true`, raises an exception on the
683
+ # first error encountered. If `false`, continues parsing and returns an
684
+ # array of the errors raised.
685
+ # @return [Array<FieldError>] An array of errors.
686
+ #
687
+ def load_json_file!(filename, raise_on_failure: false, **json_opts)
688
+ load_json!(::File.read(filename), raise_on_failure: raise_on_failure, **json_opts)
689
+ end
690
+
691
+ ##
692
+ # @private
693
+ #
694
+ # Internal get field value, with fallback to parents.
695
+ #
696
+ def get!(field)
697
+ result = @mutex.synchronize do
698
+ @values.fetch(field.name, ILLEGAL_VALUE)
699
+ end
700
+ if result != ILLEGAL_VALUE && field.container != self.class
701
+ result = field.type.call(result)
702
+ end
703
+ return result unless result == ILLEGAL_VALUE
704
+
705
+ if field.group?
706
+ inherited = @parent.get!(field) if @parent
707
+ if @fields[field.name]&.group?
708
+ @mutex.synchronize do
709
+ @values[field.name] ||= field.group_class.new(parent: inherited)
710
+ end
711
+ else
712
+ inherited
713
+ end
714
+ else
715
+ @parent ? @parent.get!(field) : field.default
716
+ end
717
+ end
718
+
719
+ ##
720
+ # @private
721
+ #
722
+ # Internal set field value, with validation.
723
+ #
724
+ def set!(field, value)
725
+ converted = field.validate(value)
726
+ @mutex.synchronize do
727
+ @values[field.name] = converted
728
+ end
729
+ end
730
+
731
+ ##
732
+ # @private
733
+ #
734
+ # Internal determine if the field is set locally.
735
+ #
736
+ def set?(field)
737
+ @mutex.synchronize do
738
+ @values.key?(field.name)
739
+ end
740
+ end
741
+
742
+ ##
743
+ # @private
744
+ #
745
+ # Internal unset field value.
746
+ #
747
+ def unset!(field)
748
+ @mutex.synchronize do
749
+ @values.delete(field.name)
750
+ end
751
+ end
752
+
753
+ ##
754
+ # @private
755
+ #
756
+ SETTINGS_TYPE = Type.new("(settings object)") do |val|
757
+ val.nil? || val.is_a?(::BasicSettings) ? val : ILLEGAL_VALUE
758
+ end
759
+
760
+ ##
761
+ # @private
762
+ #
763
+ class Field
764
+ def initialize(container, name, type_spec, default_or_group_class)
765
+ @container = container
766
+ @name = name
767
+ if type_spec == SETTINGS_TYPE
768
+ @default = nil
769
+ @group_class = default_or_group_class
770
+ @type = type_spec
771
+ else
772
+ @group_class = nil
773
+ if type_spec == DEFAULT_TYPE
774
+ @default = default_or_group_class
775
+ @type = Type.for_default_value(@default)
776
+ else
777
+ @type = Type.for_type_spec(type_spec)
778
+ @default = validate(default_or_group_class)
779
+ end
780
+ end
781
+ end
782
+
783
+ attr_reader :container
784
+ attr_reader :name
785
+ attr_reader :type
786
+ attr_reader :default
787
+ attr_reader :group_class
788
+
789
+ ##
790
+ # @return [boolean] Whether the field is a group
791
+ #
792
+ def group?
793
+ !@group_class.nil?
794
+ end
795
+
796
+ ##
797
+ # Validate the given value.
798
+ #
799
+ # @return [Object] The validated value
800
+ # @raise [FieldError] If the value cannot be validated
801
+ #
802
+ def validate(value)
803
+ validated_value = @type.call(value)
804
+ if validated_value == ILLEGAL_VALUE
805
+ raise FieldError.new(value, container, name, @type.description)
806
+ end
807
+ validated_value
808
+ end
809
+ end
810
+
811
+ class << self
812
+ ##
813
+ # Add an attribute field.
814
+ #
815
+ # @param name [Symbol,String] The name of the attribute.
816
+ # @param default [Object] Optional. The final default value if the field
817
+ # is not set in this settings object or any of its ancestors. If not
818
+ # provided, `nil` is used.
819
+ # @param type [Object] Optional. The type specification. If not provided,
820
+ # one is inferred from the default value.
821
+ #
822
+ def settings_attr(name, default: nil, type: DEFAULT_TYPE, &block)
823
+ name = interpret_name(name)
824
+ type = block if type == DEFAULT_TYPE && block
825
+ @fields[name] = field = Field.new(self, name, type, default)
826
+ create_getter(field)
827
+ create_setter(field)
828
+ create_set_detect(field)
829
+ create_unsetter(field)
830
+ self
831
+ end
832
+
833
+ ##
834
+ # Add a group field.
835
+ #
836
+ # Specify the group's structure by passing either a class (which must
837
+ # subclass BasicSettings) or a block (which will be called on the group's
838
+ # class.)
839
+ #
840
+ # @param name [Symbol, String] The name of the group.
841
+ # @param klass [Class] Optional. The class of the group (which must
842
+ # subclass BasicSettings). If not present, an anonymous subclass will
843
+ # be created, and you must provide a block to configure it.
844
+ #
845
+ def settings_group(name, klass = nil, &block)
846
+ name = interpret_name(name)
847
+ if klass.nil? == block.nil?
848
+ raise ::ArgumentError, "A group field requires a class or a block, but not both."
849
+ end
850
+ unless klass
851
+ klass = ::Class.new(::BasicSettings)
852
+ klass_name = to_class_name(name.to_s)
853
+ const_set(klass_name, klass)
854
+ klass.class_eval(&block)
855
+ end
856
+ @fields[name] = field = Field.new(self, name, SETTINGS_TYPE, klass)
857
+ create_getter(field)
858
+ self
859
+ end
860
+
861
+ ##
862
+ # @private This interface is internal and subject to change without warning.
863
+ #
864
+ # Returns the fields hash. This is shared between the settings class and
865
+ # all its instances.
866
+ #
867
+ def fields
868
+ @fields ||=
869
+ if superclass.respond_to?(:fields)
870
+ superclass.fields.dup
871
+ else
872
+ {}
873
+ end
874
+ end
875
+
876
+ ##
877
+ # @private
878
+ #
879
+ # When this base class is inherited, if its enclosing module is also a
880
+ # BasicSettings, add the new class as a group in the enclosing class.
881
+ #
882
+ def inherited(subclass)
883
+ super
884
+ subclass.fields
885
+ path = subclass.name.to_s.split("::")
886
+ namespace = path[0...-1].reduce(::Object) { |mod, name| mod.const_get(name.to_sym) }
887
+ if namespace.ancestors.include?(::BasicSettings)
888
+ name = to_field_name(path.last)
889
+ namespace.settings_group(name, subclass)
890
+ end
891
+ end
892
+
893
+ private
894
+
895
+ def to_field_name(str)
896
+ str = str.to_s.sub(/^_/, "").sub(/_$/, "").gsub(/_+/, "_")
897
+ while str.sub!(/([^_])([A-Z])/, "\\1_\\2") do end
898
+ str.downcase
899
+ end
900
+
901
+ def to_class_name(str)
902
+ str.split("_").map(&:capitalize).join
903
+ end
904
+
905
+ def interpret_name(name)
906
+ name = name.to_s
907
+ if name !~ /^[a-zA-Z]\w*$/ || RESERVED_FIELD_NAMES.include?(name)
908
+ raise ::ArgumentError, "Illegal settings field name: #{name}"
909
+ end
910
+ existing = public_instance_methods(false)
911
+ if existing.include?(name.to_sym) || existing.include?(:"#{name}=") ||
912
+ existing.include?(:"#{name}_set?") || existing.include?(:"#{name}_unset!")
913
+ raise ::ArgumentError, "Settings field already exists: #{name}"
914
+ end
915
+ name.to_sym
916
+ end
917
+
918
+ def create_getter(field)
919
+ define_method(field.name) do
920
+ get!(field)
921
+ end
922
+ end
923
+
924
+ def create_setter(field)
925
+ define_method("#{field.name}=") do |val|
926
+ set!(field, val)
927
+ end
928
+ end
929
+
930
+ def create_set_detect(field)
931
+ define_method("#{field.name}_set?") do
932
+ set?(field)
933
+ end
934
+ end
935
+
936
+ def create_unsetter(field)
937
+ define_method("#{field.name}_unset!") do
938
+ unset!(field)
939
+ end
940
+ end
941
+ end
942
+ end
metadata CHANGED
@@ -1,26 +1,38 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: basic_settings
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
- - dazuma@gmail.com
7
+ - Daniel Azuma
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: This is a placeholder gem, which was generated on 2026-04-13 to reserve
13
- the gem basic_settings. The actual gem is planned for release in the near future.
14
- If this is a problem, or if the actual gem has not been released in a timely manner,
15
- you can contact the owner at dazuma@gmail.com.
12
+ description: The basic_settings gem provides a way to define settings objects that
13
+ define the structure of application settings, i.e. the various fields that can be
14
+ set, and their types. It provides type checking, defaults inheritance, and hierarchical
15
+ structure.
16
+ email:
17
+ - dazuma@gmail.com
16
18
  executables: []
17
19
  extensions: []
18
20
  extra_rdoc_files: []
19
21
  files:
22
+ - ".yardopts"
23
+ - CHANGELOG.md
24
+ - LICENSE.md
20
25
  - README.md
21
26
  - lib/basic_settings.rb
22
- licenses: []
23
- metadata: {}
27
+ - lib/basic_settings/version.rb
28
+ homepage: https://github.com/dazuma/basic_settings
29
+ licenses:
30
+ - MIT
31
+ metadata:
32
+ changelog_uri: https://dazuma.github.io/basic_settings/gem/v0.1.0/file.CHANGELOG.html
33
+ source_code_uri: https://github.com/dazuma/basic_settings/tree/lazy_data/v0.1.0
34
+ bug_tracker_uri: https://github.com/dazuma/basic_settings/issues
35
+ documentation_uri: https://dazuma.github.io/basic_settings/gem/v0.1.0
24
36
  rdoc_options: []
25
37
  require_paths:
26
38
  - lib
@@ -28,7 +40,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
28
40
  requirements:
29
41
  - - ">="
30
42
  - !ruby/object:Gem::Version
31
- version: '0'
43
+ version: 2.7.0
32
44
  required_rubygems_version: !ruby/object:Gem::Requirement
33
45
  requirements:
34
46
  - - ">="
@@ -37,5 +49,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
37
49
  requirements: []
38
50
  rubygems_version: 4.0.6
39
51
  specification_version: 4
40
- summary: Placeholder gem
52
+ summary: A basic hierarchical settings object
41
53
  test_files: []