config_reader 3.0.4 → 3.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: c2e5e2b0131f5b621b532bb9c4e3f8e885917e7dfaa6b5797bf814a017aec114
4
- data.tar.gz: 8205f520605dd3996a210c83a0f1af805d62ed461b78bb5456b0b344e085de78
3
+ metadata.gz: d8ed2bf310952ce4e846d92391a28d703d4d33beccb4801e98525909a38c4e16
4
+ data.tar.gz: 8ec8728939e738129bc359458165ff904d754a97b5efe6331a08a02d341c4cdc
5
5
  SHA512:
6
- metadata.gz: 12e61e4b645d66265ba6a64a999eb512cdd05b79db3eb53b5b6dc599989f622b623272cf86bdcd0324da3e7a72ffe3a2cd9e278258358bb3f17fbca4572f8f6e
7
- data.tar.gz: 3675f34557595032ed99c712d8d49f022ab0f4007230e8282873ef8a424e08c040eff03c88a7242109ea72a3dd4163f302adca3968e14402a768be52cfb72fe8
6
+ metadata.gz: 67a2478345db255b9ae177058bd347f7b3d665995d77f797c25907de185c60af866824bf6001d68e6ec8dbab90badff0f8e3c0ae72db7a81d0810581ad666104
7
+ data.tar.gz: b730fa2dac028ab694207c13b1fde1019b205258e572c59e2c2c58cd3487a817c6d9636f0e33980f2ef8b7eedbeb320663c3ea3c7ba94e47dca34bed029286f1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.1.0 2026-04-18
4
+
5
+ Add `parse_path` and `dig_path` for dotted user input.
6
+ Fix config loading edge cases for false values, arrays, and missing config state.
7
+ Update the README and load Sekrets lazily unless it is configured.
8
+
3
9
  ## 3.0.4 2024-06-10
4
10
 
5
11
  Make reload actually reload, useful in development.
data/README.md CHANGED
@@ -1,59 +1,179 @@
1
1
  # ConfigReader
2
2
 
3
- [![Maintainability](https://codeclimate.com/github/UnderpantsGnome/config_reader-gem.png)](https://codeclimate.com/github/UnderpantsGnome/config_reader-gem)
4
3
  ![Specs](https://github.com/UnderpantsGnome/config_reader-gem/actions/workflows/ruby.yml/badge.svg)
5
4
  ![Ruby 3.0+](https://img.shields.io/badge/Ruby-%3E%3D%203.0-success)
6
5
 
7
- Provides a way to manage environment specific configuration settings. It will
8
- use the defaults for any environment and override any values you specify for
9
- an environment.
6
+ `ConfigReader` loads environment-specific settings from YAML, merges each
7
+ environment with `defaults`, and exposes the result through method access,
8
+ hash access, and `dig`.
10
9
 
11
- Example config file:
10
+ ## Installation
12
11
 
13
- defaults:
14
- site_url: http://localhost:3000
15
- host_name: example.com
16
- mail_from: noreply@example.com
17
- site_name: example
18
- admin_email: admin@example.com
12
+ Add the gem to your application's Gemfile:
19
13
 
20
- production:
21
- site_url: http://example.com
14
+ ```ruby
15
+ gem "config_reader"
16
+ ```
22
17
 
23
- ## Sekrets
18
+ If you use encrypted config files with `sekrets_file`, add `sekrets` too:
19
+
20
+ ```ruby
21
+ gem "sekrets", "~> 1.14"
22
+ ```
23
+
24
+ ## Config Format
24
25
 
25
- Includes Sekrets integration. See <https://github.com/ahoward/sekrets> for more
26
- information.
26
+ `defaults` is required. `config.environment` must match one of the top-level
27
+ environment keys in the file.
27
28
 
28
- The format of the sekrets file is the same as the regular file.
29
+ ```yaml
30
+ defaults:
31
+ site_url: http://localhost:3000
32
+ host_name: example.com
33
+ mail_from: noreply@example.com
34
+ features:
35
+ search: true
36
+
37
+ production:
38
+ site_url: http://example.com
39
+
40
+ test:
41
+ features:
42
+ search: false
43
+ ```
29
44
 
30
45
  ## Setup
31
46
 
32
- class MyConfig < ConfigReader
33
- configure do |config|
34
- config.environment = Rails.env # (set this however you access the env in your app)
35
- config.config_file = "config/my_config.yml"
36
- config.sekrets_file = "config/my_config.yml.enc" # (default nil)
37
- config.ignore_missing_keys = true # (default false, raises KeyError)
38
- end
39
- end
47
+ ```ruby
48
+ class MyConfig < ConfigReader
49
+ configure do |config|
50
+ config.environment = Rails.env
51
+ config.config_file = "config/my_config.yml"
52
+ config.sekrets_file = "config/my_config.yml.enc" # optional
53
+ config.ignore_missing_keys = false # default
54
+ config.permitted_classes = [] # optional
55
+ end
56
+ end
57
+ ```
58
+
59
+ `config_file` may be an exact path. If that path does not exist, ConfigReader
60
+ also checks the current directory and `config/`.
40
61
 
41
62
  ## Usage
42
63
 
43
- MyConfig.mail_from #=> noreply@example.com
44
- MyConfig[:mail_from] #=> noreply@example.com
45
- MyConfig["mail_from"] #=> noreply@example.com
64
+ Top-level and nested values are available through methods, `[]`, and `dig`:
65
+
66
+ ```ruby
67
+ MyConfig.mail_from
68
+ MyConfig[:mail_from]
69
+ MyConfig["mail_from"]
70
+
71
+ MyConfig.features.search
72
+ MyConfig[:features][:search]
73
+ MyConfig.dig(:features, :search)
74
+ ```
75
+
76
+ Arrays work with `dig` too:
77
+
78
+ ```ruby
79
+ MyConfig.dig(:servers, 0, :host)
80
+ ```
81
+
82
+ If you want to read a dotted path from user input, use `dig_path`:
83
+
84
+ ```ruby
85
+ #!/usr/bin/env ruby
46
86
 
47
- ## Note on Patches/Pull Requests
87
+ require "bundler/setup"
88
+ require_relative "../app/lib/config"
89
+
90
+ print Config.dig_path(ARGV.fetch(0))
91
+ ```
92
+
93
+ `parse_path` is also public if you need the normalized path segments:
94
+
95
+ ```ruby
96
+ MyConfig.parse_path("servers.0.host")
97
+ # => [:servers, 0, :host]
98
+ ```
99
+
100
+ String paths treat numeric segments as array indexes. If you need a literal key
101
+ that contains `.` or looks numeric, pass an array instead:
102
+
103
+ ```ruby
104
+ MyConfig.dig_path([:numeric_keys, "0"])
105
+ MyConfig.dig_path(["smtp.example.com"])
106
+ ```
107
+
108
+ You can inspect the resolved config for all environments and reload it at
109
+ runtime:
110
+
111
+ ```ruby
112
+ MyConfig.envs["production"]
113
+ MyConfig.reload
114
+ ```
115
+
116
+ By default, missing keys raise `KeyError`. To return `nil` instead:
117
+
118
+ ```ruby
119
+ class LenientConfig < ConfigReader
120
+ configure do |config|
121
+ config.environment = Rails.env
122
+ config.config_file = "config/my_config.yml"
123
+ config.ignore_missing_keys = true
124
+ end
125
+ end
126
+ ```
127
+
128
+ ## Sekrets
48
129
 
49
- * Fork the project.
50
- * Make your feature addition or bug fix.
51
- * Add tests for it. This is important so I don"t break it in a future
52
- version unintentionally.
53
- * Commit, do not mess with rakefile, version, or history. (if you want to
54
- have your own version, that is fine but bump version in a commit by itself
55
- I can ignore when I pull)
56
- * Send me a pull request. Bonus points for topic branches.
130
+ ConfigReader supports Sekrets integration, but only loads the `sekrets` gem
131
+ when `sekrets_file` is configured.
132
+
133
+ The sekrets file uses the same structure as the main config file. Sekrets
134
+ values are merged after the normal defaults plus environment merge, so matching
135
+ sekrets values override plain YAML values.
136
+
137
+ ```ruby
138
+ class SecureConfig < ConfigReader
139
+ configure do |config|
140
+ config.environment = Rails.env
141
+ config.config_file = "config/my_config.yml"
142
+ config.sekrets_file = "config/my_config.yml.enc"
143
+ end
144
+ end
145
+ ```
146
+
147
+ See <https://github.com/ahoward/sekrets> for more information.
148
+
149
+ ## Advanced YAML
150
+
151
+ ERB is evaluated before the YAML is parsed:
152
+
153
+ ```yaml
154
+ defaults:
155
+ cache_url: <%= ENV.fetch("CACHE_URL", "redis://localhost:6379/0") %>
156
+ ```
157
+
158
+ YAML is loaded with `Psych.safe_load`. `Symbol` is always permitted, and you
159
+ can allow additional classes through `permitted_classes`:
160
+
161
+ ```ruby
162
+ class TypedConfig < ConfigReader
163
+ configure do |config|
164
+ config.environment = Rails.env
165
+ config.config_file = "config/my_config.yml"
166
+ config.permitted_classes = [Date, Time]
167
+ end
168
+ end
169
+ ```
170
+
171
+ ## Contributing
172
+
173
+ - Fork the project.
174
+ - Make your change.
175
+ - Add or update tests.
176
+ - Open a pull request.
57
177
 
58
178
  ## Copyright
59
179
 
@@ -7,18 +7,26 @@ class ConfigReader
7
7
  config_hash.ignore_missing_keys = ignore_missing_keys
8
8
 
9
9
  hash.each_pair do |key, value|
10
- config_hash[key.to_sym] = if value.is_a?(Hash)
11
- convert_hash(value, ignore_missing_keys)
12
- else
13
- value
14
- end
10
+ config_hash[key.to_sym] = convert_value(value, ignore_missing_keys)
15
11
  end
16
12
 
17
13
  config_hash
18
14
  end
19
15
 
16
+ def self.convert_value(value, ignore_missing_keys = false)
17
+ if value.is_a?(Hash)
18
+ convert_hash(value, ignore_missing_keys)
19
+ elsif value.is_a?(Array)
20
+ value.map { |item| convert_value(item, ignore_missing_keys) }
21
+ else
22
+ value
23
+ end
24
+ end
25
+
20
26
  def [](key)
21
- fetch(key.to_sym)
27
+ key = key.to_sym if key.respond_to?(:to_sym)
28
+
29
+ fetch(key)
22
30
  rescue KeyError => e
23
31
  raise e unless ignore_missing_keys
24
32
  end
@@ -1,3 +1,3 @@
1
1
  class ConfigReader
2
- VERSION = "3.0.4"
2
+ VERSION = "3.1.0"
3
3
  end
data/lib/config_reader.rb CHANGED
@@ -2,11 +2,6 @@ require "config_reader/version"
2
2
  require "config_reader/config_hash"
3
3
  require "psych"
4
4
 
5
- begin
6
- require "sekrets"
7
- rescue LoadError
8
- end
9
-
10
5
  begin
11
6
  require "erb"
12
7
  rescue LoadError
@@ -45,13 +40,36 @@ class ConfigReader
45
40
  end
46
41
  end
47
42
 
43
+ def dig_path(path, separator: ".")
44
+ dig(*parse_path(path, separator: separator))
45
+ end
46
+
48
47
  def dig(*args)
49
- args.map!(&:to_sym) if args.respond_to?(:map!)
48
+ if args.respond_to?(:map!)
49
+ args.map! do |arg|
50
+ (arg.respond_to?(:to_sym) && !arg.is_a?(Integer)) ? arg.to_sym : arg
51
+ end
52
+ end
50
53
 
51
54
  config.dig(*args)
52
55
  end
53
56
 
57
+ def parse_path(path, separator: ".")
58
+ case path
59
+ when String
60
+ raise ArgumentError, "Path must not be blank" if path.strip.empty?
61
+
62
+ path.split(separator).map { |segment| normalize_string_path_segment(segment) }
63
+ when Array
64
+ path.map { |segment| normalize_array_path_segment(segment) }
65
+ else
66
+ raise ArgumentError, "Path must be a String or Array"
67
+ end
68
+ end
69
+
54
70
  def find_config
71
+ return nil unless configuration.config_file
72
+
55
73
  return configuration.config_file if File.exist?(configuration.config_file)
56
74
 
57
75
  %w[. config].each do |dir|
@@ -78,16 +96,25 @@ class ConfigReader
78
96
 
79
97
  def load_sekrets
80
98
  if configuration.sekrets_file
81
- if !defined?(::Sekrets)
82
- raise ArgumentError,
83
- "You specified a sekrets_file, but the sekrets gem isn't available."
84
- else
99
+ if sekrets_available?
85
100
  ::Sekrets.settings_for(configuration.sekrets_file) ||
86
101
  raise("No sekrets found")
102
+ else
103
+ raise ArgumentError,
104
+ "You specified a sekrets_file, but the sekrets gem isn't available."
87
105
  end
88
106
  end
89
107
  end
90
108
 
109
+ def sekrets_available?
110
+ return true if defined?(::Sekrets)
111
+
112
+ require "sekrets"
113
+ true
114
+ rescue LoadError
115
+ false
116
+ end
117
+
91
118
  def load_yaml
92
119
  permitted_classes = configuration.permitted_classes.to_a + [Symbol]
93
120
 
@@ -99,7 +126,7 @@ class ConfigReader
99
126
  )
100
127
  else
101
128
  Psych.safe_load_file(
102
- File.read(find_config),
129
+ find_config,
103
130
  aliases: true,
104
131
  permitted_classes: permitted_classes
105
132
  )
@@ -122,6 +149,7 @@ class ConfigReader
122
149
 
123
150
  def merge_configs(conf, sekrets)
124
151
  defaults = conf["defaults"]
152
+ raise "No defaults config found" unless defaults
125
153
 
126
154
  if sekrets&.[]("defaults")
127
155
  defaults = deep_merge(defaults, sekrets["defaults"])
@@ -129,7 +157,8 @@ class ConfigReader
129
157
 
130
158
  merge_all_configs(conf, defaults, sekrets)
131
159
 
132
- @envs[configuration.environment]
160
+ @envs[configuration.environment] ||
161
+ raise("No config found for environment \"#{configuration.environment}\"")
133
162
  end
134
163
 
135
164
  def method_missing(key, *_args, &_block)
@@ -137,15 +166,33 @@ class ConfigReader
137
166
  raise ArgumentError.new("ConfigReader is immutable")
138
167
  end
139
168
 
140
- config[key] || nil
169
+ config[key]
141
170
  end
142
171
 
143
172
  def reload
144
173
  @config = merge_configs(load_config, load_sekrets)
145
174
  end
146
175
 
147
- def respond_to_missing?(m, *)
148
- config.key?(m)
176
+ def respond_to_missing?(m, include_private = false)
177
+ super || config.key?(m)
178
+ end
179
+
180
+ def normalize_array_path_segment(segment)
181
+ if segment.is_a?(String) || segment.is_a?(Symbol)
182
+ segment.to_sym
183
+ elsif segment.is_a?(Integer)
184
+ segment
185
+ else
186
+ raise ArgumentError, "Path segments must be Strings, Symbols, or Integers"
187
+ end
188
+ end
189
+
190
+ def normalize_string_path_segment(segment)
191
+ if segment.is_a?(String) && segment.match?(/\A\d+\z/)
192
+ segment.to_i
193
+ else
194
+ normalize_array_path_segment(segment)
195
+ end
149
196
  end
150
197
  end
151
198
 
@@ -53,6 +53,70 @@ describe "ConfigReader" do
53
53
  end
54
54
  end
55
55
 
56
+ describe ".parse_path" do
57
+ it "parses dotted paths into dig segments" do
58
+ expect(TestConfig.parse_path("nested_key.value")).to eq(%i[nested_key value])
59
+ end
60
+
61
+ it "parses array indexes from dotted paths" do
62
+ expect(TestConfig.parse_path("items.0.name")).to eq([:items, 0, :name])
63
+ end
64
+
65
+ it "normalizes array input" do
66
+ expect(TestConfig.parse_path(["nested_key", :value, 0])).to eq(
67
+ [:nested_key, :value, 0]
68
+ )
69
+ end
70
+
71
+ it "rejects blank strings" do
72
+ expect { TestConfig.parse_path("") }.to raise_error(
73
+ ArgumentError,
74
+ "Path must not be blank"
75
+ )
76
+ end
77
+
78
+ it "rejects unsupported types" do
79
+ expect { TestConfig.parse_path(Object.new) }.to raise_error(
80
+ ArgumentError,
81
+ "Path must be a String or Array"
82
+ )
83
+ end
84
+ end
85
+
86
+ describe ".dig_path" do
87
+ it "digs through dotted paths" do
88
+ expect(TestConfig.dig_path("nested_key.value")).to eq("test")
89
+ end
90
+
91
+ it "digs through arrays" do
92
+ config_class = Class.new(ConfigReader) do
93
+ class << self
94
+ def reload
95
+ ConfigReader::ConfigHash.convert_hash(
96
+ "items" => [{ "name" => "first" }]
97
+ )
98
+ end
99
+ end
100
+ end
101
+
102
+ expect(config_class.dig_path("items.0.name")).to eq("first")
103
+ end
104
+
105
+ it "allows array input for numeric-looking keys" do
106
+ config_class = Class.new(ConfigReader) do
107
+ class << self
108
+ def reload
109
+ ConfigReader::ConfigHash.convert_hash(
110
+ "numeric_keys" => { "0" => "zero" }
111
+ )
112
+ end
113
+ end
114
+ end
115
+
116
+ expect(config_class.dig_path([:numeric_keys, "0"])).to eq("zero")
117
+ end
118
+ end
119
+
56
120
  describe "ignoring KeyNotFound" do
57
121
  it "should not raise on missing key with [] accessor" do
58
122
  expect { NoKeyNoErrorConfig[:no_key] }.to_not raise_error
@@ -145,21 +209,144 @@ describe "ConfigReader" do
145
209
  expect(SekretsConfig["nested_key"]["value"]).to eq("test_sekret")
146
210
  end
147
211
 
148
- it "should find sekrets only nested values using method_missing" do
149
- expect(SekretsConfig.nested_key.value).to eq("test_sekret")
212
+ it "should find values that only exist in sekrets" do
213
+ config_class = Class.new(ConfigReader) do
214
+ class << self
215
+ def load_config
216
+ {
217
+ "defaults" => { "app_name" => "default_app" },
218
+ "test" => { "app_name" => "test_app" }
219
+ }
220
+ end
221
+
222
+ def load_sekrets
223
+ {
224
+ "test" => {
225
+ "sekrets_only" => { "value" => "test_sekret_only" }
226
+ }
227
+ }
228
+ end
229
+ end
230
+
231
+ configure do |config|
232
+ config.environment = "test"
233
+ end
234
+ end
235
+
236
+ expect(config_class.sekrets_only.value).to eq("test_sekret_only")
237
+ expect(config_class[:sekrets_only][:value]).to eq("test_sekret_only")
238
+ expect(config_class["sekrets_only"]["value"]).to eq("test_sekret_only")
150
239
  end
151
240
 
152
- it "should find sekrets only nested values using [] and a symbol" do
153
- expect(SekretsConfig[:nested_key][:value]).to eq("test_sekret")
241
+ it "shouldn't need to have all keys duplicated in the environment section" do
242
+ expect(SekretsConfig.nested_key.only_in_test_env).to be true
154
243
  end
244
+ end
245
+ end
155
246
 
156
- it "should find sekrets only nested values using [] and a string" do
157
- expect(SekretsConfig["nested_key"]["value"]).to eq("test_sekret")
247
+ describe "regressions" do
248
+ it "does not load sekrets on a plain require" do
249
+ ruby_code = <<~RUBY
250
+ require "config_reader"
251
+ abort("sekrets loaded") if defined?(::Sekrets)
252
+ RUBY
253
+
254
+ stdout, stderr, status = Open3.capture3(
255
+ "bundle",
256
+ "exec",
257
+ "ruby",
258
+ "-Ilib",
259
+ "-e",
260
+ ruby_code,
261
+ chdir: File.expand_path("..", __dir__)
262
+ )
263
+
264
+ expect(status.success?).to be(true), stderr
265
+ expect(stdout).to eq("")
266
+ end
267
+
268
+ it "returns false for existing false values via method access" do
269
+ config_class = Class.new(ConfigReader) do
270
+ class << self
271
+ def reload
272
+ ConfigReader::ConfigHash.convert_hash("feature_enabled" => false)
273
+ end
274
+ end
158
275
  end
159
276
 
160
- it "shouldn't need to have all keys duplicated in the environment section" do
161
- expect(SekretsConfig.nested_key.only_in_test_env).to be true
277
+ expect(config_class.feature_enabled).to be(false)
278
+ end
279
+
280
+ it "raises a clear error when config_file is not set" do
281
+ config_class = Class.new(ConfigReader) do
282
+ configure do |config|
283
+ config.environment = "test"
284
+ end
162
285
  end
286
+
287
+ expect { config_class.load_config }.to raise_error("No config file set")
288
+ end
289
+
290
+ it "raises a clear error when the configured environment is missing" do
291
+ config_class = Class.new(ConfigReader) do
292
+ configure do |config|
293
+ config.environment = "missing"
294
+ config.config_file = "spec/test_config.yml"
295
+ end
296
+ end
297
+
298
+ expect { config_class.reload }.to raise_error(
299
+ 'No config found for environment "missing"'
300
+ )
301
+ end
302
+
303
+ it "loads YAML without ERB support" do
304
+ config_class = Class.new(ConfigReader) do
305
+ configure do |config|
306
+ config.environment = "test"
307
+ config.config_file = "spec/test_config.yml"
308
+ end
309
+ end
310
+
311
+ hide_const("ERB")
312
+
313
+ expect(config_class.load_yaml.dig("test", "app_name")).to eq("test_app")
314
+ end
315
+
316
+ it "supports arrays when digging through config values" do
317
+ config_class = Class.new(ConfigReader) do
318
+ class << self
319
+ def reload
320
+ ConfigReader::ConfigHash.convert_hash(
321
+ "items" => [{ "name" => "first", "enabled" => false }]
322
+ )
323
+ end
324
+ end
325
+ end
326
+
327
+ expect(config_class[:items][0][:name]).to eq("first")
328
+ expect(config_class.dig(:items, 0, :name)).to eq("first")
329
+ expect(config_class.dig("items", 0, "enabled")).to be(false)
330
+ end
331
+
332
+ it "raises a clear error when defaults are missing" do
333
+ config_class = Class.new(ConfigReader) do
334
+ class << self
335
+ def load_config
336
+ { "test" => { "app_name" => "test_app" } }
337
+ end
338
+
339
+ def load_sekrets
340
+ nil
341
+ end
342
+ end
343
+
344
+ configure do |config|
345
+ config.environment = "test"
346
+ end
347
+ end
348
+
349
+ expect { config_class.reload }.to raise_error("No defaults config found")
163
350
  end
164
351
  end
165
352
  end
data/spec/spec_helper.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  $LOAD_PATH.unshift(File.dirname(__FILE__))
2
2
  $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
3
3
 
4
+ require "open3"
4
5
  require "config_reader"
5
6
  require "test_config"
6
7
  require "sekrets_config"
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: config_reader
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.4
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Moen
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-01-24 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: abbrev
@@ -144,8 +143,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
144
143
  - !ruby/object:Gem::Version
145
144
  version: '0'
146
145
  requirements: []
147
- rubygems_version: 3.5.11
148
- signing_key:
146
+ rubygems_version: 3.6.9
149
147
  specification_version: 4
150
148
  summary: Provides a way to manage environment specific configuration settings.
151
149
  test_files: []