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 +4 -4
- data/CHANGELOG.md +10 -0
- data/Gemfile +1 -0
- data/lib/legion/settings/overlay.rb +78 -0
- data/lib/legion/settings/project_env.rb +120 -0
- data/lib/legion/settings/version.rb +1 -1
- data/lib/legion/settings.rb +48 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '028759fea7f4684e657e735676b17ebe147f71781e8bf3aac758fb226c8be81d'
|
|
4
|
+
data.tar.gz: 56d57df1ca491e5976c2f654701aad0d93fd8d3cf8347c4a4e2c9eb3a487941a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
data/lib/legion/settings.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|