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.
@@ -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,15 @@
1
+
2
+ class Settings
3
+
4
+ # Root of a settings definition tree - contains a set of groups and entries
5
+ class Root < Settings::Group
6
+
7
+ # Construct ourselves
8
+ def initialize
9
+ # We're a group...
10
+ super(nil, '')
11
+ end
12
+
13
+ end
14
+
15
+ 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,4 @@
1
+ # A comment
2
+ val1 200
3
+ val2 'dog' + 'bone'
4
+ group1.val3 :a_symbol
@@ -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