iron-settings 1.0.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.
- data/.rspec +1 -0
- data/History.txt +9 -0
- data/LICENSE +20 -0
- data/README.rdoc +133 -0
- data/Version.txt +1 -0
- data/db/settings_migration.rb +23 -0
- data/lib/iron/rake_loader.rb +6 -0
- data/lib/iron/settings.rb +161 -0
- data/lib/iron/settings/builder.rb +72 -0
- data/lib/iron/settings/class_level.rb +122 -0
- data/lib/iron/settings/cursor.rb +170 -0
- data/lib/iron/settings/db_store.rb +57 -0
- data/lib/iron/settings/db_value.rb +33 -0
- data/lib/iron/settings/entry.rb +26 -0
- data/lib/iron/settings/group.rb +102 -0
- data/lib/iron/settings/instance_level.rb +98 -0
- data/lib/iron/settings/node.rb +46 -0
- data/lib/iron/settings/root.rb +15 -0
- data/lib/iron/settings/static_store.rb +85 -0
- data/lib/iron/settings/value_store.rb +88 -0
- data/lib/tasks/settings.rake +65 -0
- data/spec/samples/static-test +4 -0
- data/spec/samples/static-test-2 +1 -0
- data/spec/settings/builder_spec.rb +48 -0
- data/spec/settings/class_level_spec.rb +46 -0
- data/spec/settings/cursor_spec.rb +96 -0
- data/spec/settings/db_store_spec.rb +12 -0
- data/spec/settings/db_value_spec.rb +58 -0
- data/spec/settings/entry_spec.rb +34 -0
- data/spec/settings/group_spec.rb +35 -0
- data/spec/settings/instance_level_spec.rb +44 -0
- data/spec/settings/settings_spec.rb +61 -0
- data/spec/settings/static_store_spec.rb +46 -0
- data/spec/settings/value_store_spec.rb +55 -0
- data/spec/spec_helper.rb +50 -0
- metadata +114 -0
@@ -0,0 +1,46 @@
|
|
1
|
+
class Settings
|
2
|
+
|
3
|
+
# Base class for groups and entries - provides our structure
|
4
|
+
class Node
|
5
|
+
|
6
|
+
# Used to separate node names in a full key
|
7
|
+
NODE_SEPARATOR = '.'.freeze
|
8
|
+
|
9
|
+
# All nodes have these items...
|
10
|
+
attr_accessor :root, :parent, :name, :key
|
11
|
+
|
12
|
+
def initialize(parent, name = nil)
|
13
|
+
# Validate name
|
14
|
+
unless parent.nil? || name.match(/[a-z0-9_]+/)
|
15
|
+
raise ArgumentError.new("Invalid settings key name '#{name}' - may only contain a-z, 0-9 and _ characters")
|
16
|
+
end
|
17
|
+
|
18
|
+
@parent = parent
|
19
|
+
@name = name
|
20
|
+
|
21
|
+
if @parent.nil?
|
22
|
+
# We are the root!
|
23
|
+
@root = self
|
24
|
+
@key = nil
|
25
|
+
else
|
26
|
+
# Normal node, chain ourselves
|
27
|
+
@root = parent.root
|
28
|
+
if parent.key.blank?
|
29
|
+
@key = name
|
30
|
+
else
|
31
|
+
@key = [@parent.key, name].join(NODE_SEPARATOR)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def group?
|
37
|
+
false
|
38
|
+
end
|
39
|
+
|
40
|
+
def entry?
|
41
|
+
false
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
class Settings
|
2
|
+
|
3
|
+
# Provides in-memory static (aka file and code-based) settings, suitable for class-level settings
|
4
|
+
# for gems, frameworks, command-line tools, etc.
|
5
|
+
class StaticStore < Settings::ValueStore
|
6
|
+
|
7
|
+
attr_accessor :paths
|
8
|
+
|
9
|
+
def initialize(root, options = {})
|
10
|
+
file = options.delete(:file)
|
11
|
+
files = options.delete(:files)
|
12
|
+
@secure = !(options.delete(:secure) === false)
|
13
|
+
@ignore_missing = !(options.delete(:ignore_missing) === false)
|
14
|
+
super(root, options)
|
15
|
+
|
16
|
+
@paths = []
|
17
|
+
@paths << file if file
|
18
|
+
@paths += files || []
|
19
|
+
|
20
|
+
@modified_time = @paths.convert_to_hash(nil)
|
21
|
+
end
|
22
|
+
|
23
|
+
# True on our reload settings matching (see ValueStore#need_reload?), or if
|
24
|
+
# any of our settings files have been modified since our last load.
|
25
|
+
def need_reload?
|
26
|
+
return true if super
|
27
|
+
@modified_time.any? do |path, time|
|
28
|
+
File.exist?(path) && File.mtime(path) != time
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# We don't support saving our current state, hence "static" :-)
|
33
|
+
def read_only?
|
34
|
+
true
|
35
|
+
end
|
36
|
+
|
37
|
+
# Load our values from the file(s) specified during creation, in order,
|
38
|
+
# respecting the :secure option to only load safe settings files
|
39
|
+
# if so specified.
|
40
|
+
def load
|
41
|
+
super
|
42
|
+
@paths.each {|p| load_file(p) }
|
43
|
+
end
|
44
|
+
|
45
|
+
# Loads a single settings file, verifying its existence, ownership/security, etc.
|
46
|
+
#
|
47
|
+
def load_file(path)
|
48
|
+
# Ensure we have the file, if so required
|
49
|
+
raise RuntimeError.new("Missing settings file #{path} - this file is required") unless @ignore_missing || File.exists?(path)
|
50
|
+
|
51
|
+
# Read in the settings file
|
52
|
+
verify_file_security(path)
|
53
|
+
text = File.read(path) rescue ''
|
54
|
+
return if text.blank?
|
55
|
+
|
56
|
+
# Create a cursor, and eval the file's contents in the cursor's context
|
57
|
+
cursor = Settings::Cursor.new(@root, self)
|
58
|
+
cursor.eval_in_context(text)
|
59
|
+
|
60
|
+
# Remember our modified time for future checking
|
61
|
+
@modified_time[path] = File.mtime(path)
|
62
|
+
end
|
63
|
+
|
64
|
+
def save_file(path)
|
65
|
+
@modified_time[path] = File.mtime(path)
|
66
|
+
raise 'Unimplemented!'
|
67
|
+
end
|
68
|
+
|
69
|
+
# Implements :secure test for settings files. Verifies that the specified file is:
|
70
|
+
#
|
71
|
+
# * Owned by the same user ID that is running the current process
|
72
|
+
# * Not world-writable
|
73
|
+
#
|
74
|
+
def verify_file_security(path)
|
75
|
+
# Not requiring security? File doesn't exist? Then everything is fine...
|
76
|
+
return unless (File.exists?(path) && @secure)
|
77
|
+
|
78
|
+
stat = File::Stat.new(path)
|
79
|
+
raise RuntimeError.new("Cannot load settings file #{path} - file must be owned by the user this program is running as (UID #{Process.uid})") unless stat.owned?
|
80
|
+
raise RuntimeError.new("Cannot load settings file #{path} - file cannot be world-writable") if stat.world_writable?
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
84
|
+
|
85
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
class Settings
|
2
|
+
|
3
|
+
# Base class for our value stores. Derived classes manage loading and saving
|
4
|
+
# values in the value hash.
|
5
|
+
class ValueStore
|
6
|
+
|
7
|
+
def initialize(root, options = {})
|
8
|
+
@root = root
|
9
|
+
@options = options
|
10
|
+
@loaded_on = nil
|
11
|
+
@reload = options.delete(:reload) || false
|
12
|
+
@values = {}
|
13
|
+
end
|
14
|
+
|
15
|
+
def need_reload?
|
16
|
+
# Always reload at first chance, ie LOAD, duh
|
17
|
+
return true if @loaded_on.nil?
|
18
|
+
|
19
|
+
# Do the right thing
|
20
|
+
case @reload
|
21
|
+
when true then
|
22
|
+
# Always reload each time #settings creates a new cursor
|
23
|
+
true
|
24
|
+
|
25
|
+
when false then
|
26
|
+
# Never reload
|
27
|
+
false
|
28
|
+
|
29
|
+
when Proc then
|
30
|
+
# Custom reload handler, reload on returning true
|
31
|
+
@reload.call === true
|
32
|
+
|
33
|
+
when Fixnum then
|
34
|
+
# Reload after N seconds
|
35
|
+
Time.now > @loaded_on + @reload.to_i
|
36
|
+
|
37
|
+
when String then
|
38
|
+
# Reload if file is modified
|
39
|
+
mod_time = File.mtime(@reload) rescue nil
|
40
|
+
mod_time.nil? || @loaded_on < mod_time
|
41
|
+
|
42
|
+
else
|
43
|
+
# Non-standard reload setting, must be handled in kids
|
44
|
+
nil
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def reload_if_needed
|
49
|
+
load if need_reload?
|
50
|
+
end
|
51
|
+
|
52
|
+
def load
|
53
|
+
@loaded_on = Time.now
|
54
|
+
@values = {}
|
55
|
+
end
|
56
|
+
|
57
|
+
def save
|
58
|
+
# No saving for me, thanks
|
59
|
+
return if read_only?
|
60
|
+
|
61
|
+
# Update our timestamp on our cache reload file, if any
|
62
|
+
if @reload.is_a?(String)
|
63
|
+
FileUtils.touch(@reload)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Remember when we were loaded for future use
|
67
|
+
@loaded_on = Time.now
|
68
|
+
end
|
69
|
+
|
70
|
+
def has_value?(key)
|
71
|
+
@values.has_key?(key)
|
72
|
+
end
|
73
|
+
|
74
|
+
def get_value(key)
|
75
|
+
@values[key]
|
76
|
+
end
|
77
|
+
|
78
|
+
def set_value(key, value)
|
79
|
+
@values[key] = value
|
80
|
+
end
|
81
|
+
|
82
|
+
def read_only?
|
83
|
+
false
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
|
88
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
namespace :settings do
|
2
|
+
|
3
|
+
desc "Install components required for integration of the iron-settings gem with your Rails app"
|
4
|
+
task :install => :environment do
|
5
|
+
spec = Gem::Specification.find_by_name("iron-settings")
|
6
|
+
gem_root = spec.gem_dir
|
7
|
+
|
8
|
+
# Our destination directory
|
9
|
+
dir = File.join(Rails.root, 'db/migrate')
|
10
|
+
|
11
|
+
# Our source migration files
|
12
|
+
src = File.join(gem_root, 'db/settings_migration.rb')
|
13
|
+
filename = Time.now.strftime('%Y%m%d%H%M%S') + '_' + File.basename(src)
|
14
|
+
dest = File.join(dir, filename)
|
15
|
+
|
16
|
+
puts ''
|
17
|
+
puts "Installing settings migration(s) into #{dir} as:"
|
18
|
+
puts " #{filename}"
|
19
|
+
puts ''
|
20
|
+
|
21
|
+
if Dir.glob(File.join(dir, '*_' + File.basename(src))).empty?
|
22
|
+
FileUtils.cp(src, dest)
|
23
|
+
if File.exist?(dest)
|
24
|
+
puts "Migration installed successfully, please remember to run rake db:migrate to complete the installation"
|
25
|
+
else
|
26
|
+
puts "ERROR: migration file not created"
|
27
|
+
puts ''
|
28
|
+
puts "You may need to manually install the migration located here:"
|
29
|
+
puts " #{src}"
|
30
|
+
end
|
31
|
+
else
|
32
|
+
puts "Migration already installed - skipping!"
|
33
|
+
end
|
34
|
+
|
35
|
+
puts ''
|
36
|
+
end
|
37
|
+
|
38
|
+
desc "Display all possible settings and their current values"
|
39
|
+
task :list => :environment do
|
40
|
+
Settings.classes.each do |klass|
|
41
|
+
puts "\n#{klass.name} Settings"
|
42
|
+
puts "----------------"
|
43
|
+
settings = klass.settings
|
44
|
+
groups = [settings.root]
|
45
|
+
until groups.empty?
|
46
|
+
# Get the next group to work on
|
47
|
+
group = groups.shift
|
48
|
+
|
49
|
+
# Run each entry and display its current value
|
50
|
+
group.entries(false).sort_by(&:key).each do |entry|
|
51
|
+
info = entry.key + ' = ' + settings.item_value(entry).inspect + " (#{entry.type})"
|
52
|
+
unless klass.settings.item_has_value?(entry)
|
53
|
+
info = "\e[1;30m" + info + "\e[0;9m"
|
54
|
+
end
|
55
|
+
puts info
|
56
|
+
end
|
57
|
+
|
58
|
+
# Add in sub-groups for next iteration, depth-first-wise
|
59
|
+
groups = group.groups(false).sort_by(&:key) + groups
|
60
|
+
end
|
61
|
+
end
|
62
|
+
puts ""
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
val1 205
|
@@ -0,0 +1,48 @@
|
|
1
|
+
describe Settings::Builder do
|
2
|
+
|
3
|
+
before do
|
4
|
+
@root = Settings::Root.new
|
5
|
+
@builder = Settings::Builder.new(@root)
|
6
|
+
end
|
7
|
+
|
8
|
+
it 'should add groups' do
|
9
|
+
@builder.group('bob')
|
10
|
+
group = @root.find_group('bob')
|
11
|
+
group.should be_group
|
12
|
+
group.key.should == 'bob'
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'should return a builder after adding a group' do
|
16
|
+
@builder.group('bob').should be_a(Settings::Builder)
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'should add string entries' do
|
20
|
+
@builder.string('foo')
|
21
|
+
entry = @root.find_entry('foo')
|
22
|
+
entry.should be_entry
|
23
|
+
entry.key.should == 'foo'
|
24
|
+
entry.type.should == :string
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'should support all built-in data types' do
|
28
|
+
Settings.data_types.each do |type|
|
29
|
+
@builder.should respond_to(type)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'should raise an error if defaults are not parseable' do
|
34
|
+
expect { @builder.string('foo', 123) }.to raise_error(ArgumentError)
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'should allow procs as defaults' do
|
38
|
+
expect { @builder.string('yo', lambda { 'hi' }) }.to_not raise_error
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'should reject key names that are invalid' do
|
42
|
+
expect { @builder.int('bad.key') }.to raise_error
|
43
|
+
expect { @builder.int('another-baddy') }.to raise_error
|
44
|
+
expect { @builder.int('0isnotok') }.to raise_error
|
45
|
+
expect { @builder.group('key.of.badness') }.to raise_error
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
describe Settings::ClassLevel do
|
2
|
+
|
3
|
+
class SimpleTest
|
4
|
+
class_settings do
|
5
|
+
# General settings
|
6
|
+
string('key', 'changeme')
|
7
|
+
group('session') do
|
8
|
+
string('secret')
|
9
|
+
string('key') { '_' + key + '_session' }
|
10
|
+
int('timeout')
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class StaticTest
|
16
|
+
class_settings(:file => SpecHelper.sample_path('static-test')) do
|
17
|
+
int('val1', 10)
|
18
|
+
string('val2')
|
19
|
+
group('group1') do
|
20
|
+
symbol('val3')
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'should be available in all Modules' do
|
26
|
+
Module.should respond_to(:class_settings)
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'should return a Builder instance' do
|
30
|
+
class Bob ; end
|
31
|
+
Bob.class_settings.should be_a(Settings::Builder)
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'should not be available at instance level' do
|
35
|
+
test = SimpleTest.new
|
36
|
+
test.respond_to?(:settings).should be_false
|
37
|
+
end
|
38
|
+
|
39
|
+
# Basically an integration test
|
40
|
+
it 'should load values from a file' do
|
41
|
+
StaticTest.settings.val1.should == 200
|
42
|
+
StaticTest.settings.val2.should == 'dogbone'
|
43
|
+
StaticTest.settings.group1.val3.should == :a_symbol
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
describe Settings::Cursor do
|
2
|
+
|
3
|
+
class CursorTest
|
4
|
+
class_settings do
|
5
|
+
int('val1', 5)
|
6
|
+
int('val2')
|
7
|
+
group('top') do
|
8
|
+
group('middle') do
|
9
|
+
group('bottom') do
|
10
|
+
string('leaf')
|
11
|
+
int('lamby') { val1 + 1 }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
string('invalid_default', lambda { 5 })
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
before do
|
20
|
+
# Clear out temp data and reset to blank slate
|
21
|
+
CursorTest.class_settings_reset!
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'should traverse from group to group' do
|
25
|
+
CursorTest.settings.top?.should be_true
|
26
|
+
CursorTest.settings.top.middle?.should be_true
|
27
|
+
CursorTest.settings.top.middle.bottom?.should be_true
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'should test for value presence using interogator' do
|
31
|
+
CursorTest.settings.val2?.should be_false
|
32
|
+
CursorTest.settings.val2 100
|
33
|
+
CursorTest.settings.val2?.should be_true
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'should set via dsl-style and explicit setters' do
|
37
|
+
CursorTest.settings.val1 = 5
|
38
|
+
CursorTest.settings.val1.should == 5
|
39
|
+
CursorTest.settings.val1 10
|
40
|
+
CursorTest.settings.val1.should == 10
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'should handle block-style setters' do
|
44
|
+
CursorTest.settings.val1.should == 5
|
45
|
+
CursorTest.settings do
|
46
|
+
val1 6
|
47
|
+
val2 0
|
48
|
+
end
|
49
|
+
CursorTest.settings.val1.should == 6
|
50
|
+
CursorTest.settings.val2.should == 0
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'should raise on setting an entry to an incorrect value type' do
|
54
|
+
expect { CursorTest.settings.val1 = 'not an integer' }.to raise_error
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'should raise an error on accessing an entry with a default proc that returns the wrong data type' do
|
58
|
+
expect { CursorTest.settings.invalid_default }.to raise_error(ArgumentError)
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'should support getting values via key strings' do
|
62
|
+
CursorTest.settings['val1'].should == 5
|
63
|
+
CursorTest.settings.top.middle.bottom.leaf = 'yo'
|
64
|
+
CursorTest.settings['top.middle.bottom.leaf'].should == 'yo'
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'should support setting values via key strings' do
|
68
|
+
CursorTest.settings['top.middle.bottom.leaf'] = 'yo'
|
69
|
+
CursorTest.settings.top.middle.bottom.leaf.should == 'yo'
|
70
|
+
end
|
71
|
+
|
72
|
+
it 'should return a cursor on getting a group\'s value using key access' do
|
73
|
+
CursorTest.settings['top'].should be_a(Settings::Cursor)
|
74
|
+
end
|
75
|
+
|
76
|
+
it 'should return an array of all keys' do
|
77
|
+
CursorTest.settings.entry_keys.should match_array([
|
78
|
+
'val1',
|
79
|
+
'val2',
|
80
|
+
'top.middle.bottom.lamby',
|
81
|
+
'top.middle.bottom.leaf',
|
82
|
+
'invalid_default'
|
83
|
+
])
|
84
|
+
end
|
85
|
+
|
86
|
+
it 'should return keys relative to its bound group' do
|
87
|
+
CursorTest.settings.top.middle.entry_keys.should match_array([
|
88
|
+
'bottom.leaf', 'bottom.lamby'
|
89
|
+
])
|
90
|
+
end
|
91
|
+
|
92
|
+
it 'should eval lambda defaults in the context of the root cursor' do
|
93
|
+
CursorTest.settings.top.middle.bottom.lamby.should == 6
|
94
|
+
end
|
95
|
+
|
96
|
+
end
|