legion-settings 1.3.24 → 1.3.25

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: 482b26bda825959da123ce4b68f0503181bbc05a2af3bbbbc912d067e76ae2cb
4
- data.tar.gz: 45c70811a101925a22ec51244b6f8e42e1ad9d8cacc02eb8374c56eb11b6a9da
3
+ metadata.gz: '028759fea7f4684e657e735676b17ebe147f71781e8bf3aac758fb226c8be81d'
4
+ data.tar.gz: 56d57df1ca491e5976c2f654701aad0d93fd8d3cf8347c4a4e2c9eb3a487941a
5
5
  SHA512:
6
- metadata.gz: b063262f1a803400f2d86fbdbfe62d7778f9c2c8f01bd78baf73d74216b25417c4ae53791cacced67024bad384d4e3d3fab5239b729d568f636496f6a5b43170
7
- data.tar.gz: b8d8c69b34ef0688e2d82b7ad88a175c59ee2dfb48f64267fa409ad26b29ae7394213f0947a6da73c07479f5df65390f75e15f602957eab031aeee14be261c59
6
+ metadata.gz: c75da04c00df4cd9db84c50c3327e9b5c80ad5a4137531cb1aad9d5e8efc8809736d97359c8de2c791aed1848e1b2b0c2948d27a50fee24ed3263e54de9d63ab
7
+ data.tar.gz: 51d80d06a89f8ccc4284d6ff90741e2f083f08827ecd8c6ab853d7ff913aad24780ac22e25b0999fcd9464544161b15f5983bd824ea62ac3cfce6ca4e3b1acd2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Legion::Settings Changelog
2
2
 
3
+ ## [1.3.25] - 2026-03-31
4
+
5
+ ### Added
6
+ - `Settings.with_overlay(overrides) { }` — thread-local request-scoped settings overlay for per-tenant LLM routing without node restarts; nestable, cleans up via ensure block (closes #9)
7
+ - `Settings.load_project_env(start_dir:)` — discovers and loads `.legionio.env` from Dir.pwd upward; dot-notation keys (`llm.default_model=haiku`) map to nested settings paths; auto-called during `Settings.load` (closes #10)
8
+ - `Legion::Settings::Overlay` module — thread-local overlay storage with `with_overlay`, `current_overlay`, `overlay_for`, `clear_overlay!`
9
+ - `Legion::Settings::ProjectEnv` module — env file discovery, parsing, and merging
10
+ - Resolution order: request overlay > project `.legionio.env` > global settings
11
+ - 44 new specs covering overlay scoping/nesting/cleanup, thread isolation, `.legionio.env` parsing, discovery, key mapping, and resolution order
12
+
3
13
  ## [1.3.24] - 2026-03-30
4
14
 
5
15
  ### Added
data/Gemfile CHANGED
@@ -10,5 +10,6 @@ group :test do
10
10
  gem 'rspec'
11
11
  gem 'rspec_junit_formatter'
12
12
  gem 'rubocop'
13
+ gem 'rubocop-legion'
13
14
  gem 'simplecov'
14
15
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Settings
5
+ # Thread-local request-scoped settings overlay.
6
+ #
7
+ # Provides block-scoped overrides that sit above global settings in the
8
+ # resolution order: request overlay > project .legionio.env > global settings.
9
+ #
10
+ # Usage:
11
+ # Legion::Settings.with_overlay(llm: { default_model: 'claude-3-haiku' }) do
12
+ # Legion::Settings[:llm][:default_model] # => 'claude-3-haiku'
13
+ # end
14
+ #
15
+ # Overlays are nestable — inner overlay merges on top of the outer one.
16
+ module Overlay
17
+ THREAD_KEY = :legion_settings_overlay
18
+
19
+ class << self
20
+ # Execute a block with the given overrides active in the current thread.
21
+ # The overrides hash uses the same top-level key structure as Settings.
22
+ #
23
+ # @param overrides [Hash] settings to override for the duration of the block
24
+ # @yield block executed with the overlay active
25
+ # @return the return value of the block
26
+ def with_overlay(overrides)
27
+ previous = Thread.current[THREAD_KEY]
28
+ Thread.current[THREAD_KEY] = deep_merge(previous || {}, overrides)
29
+ yield
30
+ ensure
31
+ Thread.current[THREAD_KEY] = previous
32
+ end
33
+
34
+ # Return the current thread-local overlay hash, or nil if none is active.
35
+ #
36
+ # @return [Hash, nil]
37
+ def current_overlay
38
+ Thread.current[THREAD_KEY]
39
+ end
40
+
41
+ # Clear the thread-local overlay for the current thread.
42
+ def clear_overlay!
43
+ Thread.current[THREAD_KEY] = nil
44
+ end
45
+
46
+ # Resolve a top-level key against the active overlay, returning the
47
+ # overlay value (which may need to be merged with base) or nil when no
48
+ # overlay is set.
49
+ #
50
+ # @param key [Symbol, String]
51
+ # @return [Object, nil]
52
+ def overlay_for(key)
53
+ overlay = Thread.current[THREAD_KEY]
54
+ return nil unless overlay
55
+
56
+ sym_key = key.to_sym
57
+ str_key = key.to_s
58
+ overlay[sym_key] || overlay[str_key]
59
+ end
60
+
61
+ private
62
+
63
+ def deep_merge(base, overrides)
64
+ result = base.dup
65
+ overrides.each do |key, value|
66
+ existing = result[key]
67
+ result[key] = if existing.is_a?(Hash) && value.is_a?(Hash)
68
+ deep_merge(existing, value)
69
+ else
70
+ value
71
+ end
72
+ end
73
+ result
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Settings
5
+ # Per-project `.legionio.env` config file loader.
6
+ #
7
+ # Walks up from Dir.pwd searching for a `.legionio.env` file. When found,
8
+ # parses `KEY=VALUE` lines with dot-notation keys and merges them into the
9
+ # loader at a priority between global settings and the request overlay.
10
+ #
11
+ # File format:
12
+ # # comment lines are ignored
13
+ # llm.default_model=claude-sonnet-4-5-20241022
14
+ # cache.driver=redis
15
+ #
16
+ # Keys use dot notation to address nested settings paths.
17
+ # Values are always strings; callers should coerce as needed.
18
+ #
19
+ # Resolution order (lowest → highest priority):
20
+ # global settings < .legionio.env < request overlay (#9)
21
+ module ProjectEnv
22
+ ENV_FILENAME = '.legionio.env'
23
+
24
+ class << self
25
+ # Walk up from +start_dir+ (defaults to Dir.pwd) looking for
26
+ # `.legionio.env`. Returns the first file found, or nil.
27
+ #
28
+ # @param start_dir [String, nil] directory to start the search from
29
+ # @return [String, nil] absolute path to the file, or nil
30
+ def find_project_env_file(start_dir: nil)
31
+ dir = File.expand_path(start_dir || Dir.pwd)
32
+ loop do
33
+ candidate = File.join(dir, ENV_FILENAME)
34
+ return candidate if File.file?(candidate) && File.readable?(candidate)
35
+
36
+ parent = File.dirname(dir)
37
+ break if parent == dir # filesystem root
38
+
39
+ dir = parent
40
+ end
41
+ nil
42
+ end
43
+
44
+ # Parse a `.legionio.env` file and return a nested hash of overrides.
45
+ #
46
+ # @param path [String] absolute path to the file
47
+ # @return [Hash] nested hash with symbol keys
48
+ def parse_env_file(path)
49
+ result = {}
50
+ File.readlines(path, chomp: true).each_with_index do |line, idx|
51
+ next if line.strip.empty?
52
+ next if line.strip.start_with?('#')
53
+
54
+ parts = line.split('=', 2)
55
+ unless parts.length == 2
56
+ log_warn("#{path}:#{idx + 1}: skipping malformed line (no '=' found)")
57
+ next
58
+ end
59
+
60
+ raw_key, value = parts
61
+ key_parts = raw_key.strip.split('.')
62
+ if key_parts.empty? || key_parts.any?(&:empty?)
63
+ log_warn("#{path}:#{idx + 1}: skipping invalid key '#{raw_key.strip}'")
64
+ next
65
+ end
66
+
67
+ set_nested(result, key_parts.map(&:to_sym), value.strip)
68
+ end
69
+ result
70
+ end
71
+
72
+ # Find and load the project env file into the given settings hash,
73
+ # merging overrides (env file values win over existing values).
74
+ #
75
+ # @param settings [Hash] the settings hash to merge into (mutated in place)
76
+ # @param start_dir [String, nil] directory to start searching from
77
+ # @return [String, nil] path to the loaded file, or nil if none found
78
+ def load_into(settings, start_dir: nil)
79
+ path = find_project_env_file(start_dir: start_dir)
80
+ return nil unless path
81
+
82
+ overrides = parse_env_file(path)
83
+ deep_merge_into!(settings, overrides)
84
+ log_debug("ProjectEnv: loaded #{path}")
85
+ path
86
+ end
87
+
88
+ private
89
+
90
+ def set_nested(hash, keys, value)
91
+ *parents, leaf = keys
92
+ target = parents.reduce(hash) do |h, k|
93
+ h[k] ||= {}
94
+ h[k]
95
+ end
96
+ target[leaf] = value
97
+ end
98
+
99
+ def deep_merge_into!(base, overrides)
100
+ overrides.each do |key, value|
101
+ if base[key].is_a?(Hash) && value.is_a?(Hash)
102
+ deep_merge_into!(base[key], value)
103
+ else
104
+ base[key] = value
105
+ end
106
+ end
107
+ base
108
+ end
109
+
110
+ def log_debug(message)
111
+ defined?(Legion::Logging) ? Legion::Logging.debug(message) : nil
112
+ end
113
+
114
+ def log_warn(message)
115
+ defined?(Legion::Logging) ? Legion::Logging.warn(message) : warn(message)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Settings
5
- VERSION = '1.3.24'
5
+ VERSION = '1.3.25'
6
6
  end
7
7
  end
@@ -7,6 +7,8 @@ require 'legion/settings/loader'
7
7
  require 'legion/settings/schema'
8
8
  require 'legion/settings/validation_error'
9
9
  require 'legion/settings/helper'
10
+ require 'legion/settings/overlay'
11
+ require 'legion/settings/project_env'
10
12
 
11
13
  module Legion
12
14
  module Settings
@@ -35,6 +37,7 @@ module Legion
35
37
  end
36
38
 
37
39
  @loaded = true if has_config
40
+ load_project_env
38
41
  logger.info("Settings loaded from #{@loader.loaded_files.size} files")
39
42
  @loader
40
43
  end
@@ -50,7 +53,15 @@ module Legion
50
53
  def [](key)
51
54
  logger.info('Legion::Settings was not loading, auto loading now!') if @loader.nil?
52
55
  ensure_loader
53
- @loader[key]
56
+ overlay_val = Overlay.overlay_for(key)
57
+ base_val = @loader[key]
58
+ if overlay_val.is_a?(Hash) && base_val.is_a?(Hash)
59
+ deep_merge_for_overlay(base_val, overlay_val)
60
+ elsif !overlay_val.nil?
61
+ overlay_val
62
+ else
63
+ base_val
64
+ end
54
65
  rescue NoMethodError, TypeError => e
55
66
  Legion::Logging.debug("Legion::Settings#[] key=#{key} failed: #{e.message}") if defined?(Legion::Logging)
56
67
  nil
@@ -86,6 +97,28 @@ module Legion
86
97
  cross_validations << block
87
98
  end
88
99
 
100
+ # Execute a block with thread-local settings overrides active.
101
+ # Overlays are nestable — inner overlays merge on top of outer ones.
102
+ # Resolution order inside the block: overlay > project env > global settings.
103
+ #
104
+ # @param overrides [Hash] settings to override for the duration of the block
105
+ # @yield the block executed with the overlay active
106
+ # @return the return value of the block
107
+ def with_overlay(overrides, &)
108
+ Overlay.with_overlay(overrides, &)
109
+ end
110
+
111
+ # Load (or reload) the nearest `.legionio.env` file into the settings loader.
112
+ # Searches from Dir.pwd upward. Env-file values take priority over global
113
+ # settings but are overridden by a request overlay (with_overlay).
114
+ #
115
+ # @param start_dir [String, nil] directory to start searching from (defaults to Dir.pwd)
116
+ # @return [String, nil] path to the loaded file, or nil if none found
117
+ def load_project_env(start_dir: nil)
118
+ ensure_loader
119
+ ProjectEnv.load_into(@loader.settings, start_dir: start_dir)
120
+ end
121
+
89
122
  def dev_mode?
90
123
  return true if ENV['LEGION_DEV'] == 'true'
91
124
 
@@ -143,6 +176,7 @@ module Legion
143
176
  @loaded = nil
144
177
  @schema = nil
145
178
  @cross_validations = nil
179
+ Overlay.clear_overlay!
146
180
  end
147
181
 
148
182
  def logger
@@ -160,6 +194,19 @@ module Legion
160
194
 
161
195
  private
162
196
 
197
+ def deep_merge_for_overlay(base, overlay)
198
+ result = base.dup
199
+ overlay.each do |key, value|
200
+ existing = result[key]
201
+ result[key] = if existing.is_a?(Hash) && value.is_a?(Hash)
202
+ deep_merge_for_overlay(existing, value)
203
+ else
204
+ value
205
+ end
206
+ end
207
+ result
208
+ end
209
+
163
210
  def ensure_loader
164
211
  return @loader if @loader
165
212
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-settings
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.24
4
+ version: 1.3.25
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -54,6 +54,8 @@ files:
54
54
  - lib/legion/settings/helper.rb
55
55
  - lib/legion/settings/loader.rb
56
56
  - lib/legion/settings/os.rb
57
+ - lib/legion/settings/overlay.rb
58
+ - lib/legion/settings/project_env.rb
57
59
  - lib/legion/settings/resolver.rb
58
60
  - lib/legion/settings/schema.rb
59
61
  - lib/legion/settings/validation_error.rb