configliere 0.3.4 → 0.4.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. data/.document +3 -0
  2. data/.watchr +20 -0
  3. data/CHANGELOG.textile +99 -3
  4. data/Gemfile +26 -0
  5. data/Gemfile.lock +54 -0
  6. data/README.textile +162 -138
  7. data/Rakefile +30 -21
  8. data/VERSION +1 -1
  9. data/bin/configliere +77 -77
  10. data/bin/configliere-decrypt +85 -0
  11. data/bin/configliere-delete +85 -0
  12. data/bin/configliere-dump +85 -0
  13. data/bin/configliere-encrypt +85 -0
  14. data/bin/configliere-list +85 -0
  15. data/bin/configliere-set +85 -0
  16. data/configliere.gemspec +53 -23
  17. data/examples/config_block_script.rb +9 -2
  18. data/examples/encrypted_script.rb +28 -16
  19. data/examples/env_var_script.rb +2 -2
  20. data/examples/help_message_demo.rb +16 -0
  21. data/examples/independent_config.rb +28 -0
  22. data/examples/prompt.rb +23 -0
  23. data/examples/simple_script.rb +28 -15
  24. data/examples/simple_script.yaml +1 -1
  25. data/lib/configliere.rb +22 -24
  26. data/lib/configliere/commandline.rb +135 -116
  27. data/lib/configliere/commands.rb +38 -54
  28. data/lib/configliere/config_block.rb +4 -2
  29. data/lib/configliere/config_file.rb +30 -52
  30. data/lib/configliere/crypter.rb +8 -5
  31. data/lib/configliere/deep_hash.rb +368 -0
  32. data/lib/configliere/define.rb +83 -89
  33. data/lib/configliere/encrypted.rb +17 -18
  34. data/lib/configliere/env_var.rb +5 -7
  35. data/lib/configliere/param.rb +37 -64
  36. data/lib/configliere/prompt.rb +23 -0
  37. data/spec/configliere/commandline_spec.rb +156 -57
  38. data/spec/configliere/commands_spec.rb +75 -30
  39. data/spec/configliere/config_block_spec.rb +10 -1
  40. data/spec/configliere/config_file_spec.rb +83 -55
  41. data/spec/configliere/crypter_spec.rb +3 -2
  42. data/spec/configliere/deep_hash_spec.rb +401 -0
  43. data/spec/configliere/define_spec.rb +121 -42
  44. data/spec/configliere/encrypted_spec.rb +53 -20
  45. data/spec/configliere/env_var_spec.rb +24 -4
  46. data/spec/configliere/param_spec.rb +25 -27
  47. data/spec/configliere/prompt_spec.rb +50 -0
  48. data/spec/configliere_spec.rb +3 -9
  49. data/spec/spec_helper.rb +17 -6
  50. metadata +110 -35
  51. data/lib/configliere/core_ext.rb +0 -2
  52. data/lib/configliere/core_ext/blank.rb +0 -93
  53. data/lib/configliere/core_ext/hash.rb +0 -108
  54. data/lib/configliere/core_ext/sash.rb +0 -170
  55. data/spec/configliere/core_ext/hash_spec.rb +0 -78
  56. data/spec/configliere/core_ext/sash_spec.rb +0 -312
@@ -1,4 +1,3 @@
1
- Configliere.use :commandline
2
1
  module Configliere
3
2
 
4
3
  #
@@ -6,54 +5,39 @@ module Configliere
6
5
  #
7
6
  # To include, specify
8
7
  #
9
- # Configliere.use :commands
8
+ # Settings.use :commands
10
9
  #
11
10
  module Commands
12
-
13
11
  # The name of the command.
14
12
  attr_accessor :command_name
15
13
 
14
+ #
15
+ # FIXME: this will be refactored to look like Configliere::Define
16
+ #
17
+
16
18
  # Add a command, along with a description of its predicates and the command itself.
17
19
  def define_command cmd, options={}, &block
18
- command_configuration = Configliere.new
20
+ cmd = cmd.to_sym
21
+ command_configuration = Configliere::Param.new
22
+ command_configuration.use :commandline, :env_var
19
23
  yield command_configuration if block_given?
20
24
  commands[cmd] = options.merge(:config => command_configuration)
21
25
  end
22
26
 
23
- # Define a help command.
24
- def define_help_command!
25
- define_command :help, :description => "Print detailed help on each command"
26
- end
27
-
28
- # Are there any commands that have been defined?
29
- def commands?
30
- (! commands.empty?)
31
- end
32
-
33
- # Is +cmd+ the name of a known command?
34
- def command? cmd
35
- return false if cmd.blank?
36
- commands.include?(cmd) || commands.include?(cmd.to_s)
37
- end
38
-
39
27
  def commands
40
- @commands ||= Sash.new
41
- end
42
-
43
- def command
44
- command_name && commands[command_name]
28
+ @commands ||= DeepHash.new
45
29
  end
46
30
 
47
- # The Param object for the command
48
- def command_settings
49
- command && command[:config]
31
+ def command_info
32
+ commands[command_name]
50
33
  end
51
34
 
52
35
  def resolve!
53
36
  super()
54
- commands.each_value do |command|
55
- command[:config].resolve!
37
+ commands.each_value do |cmd_info|
38
+ cmd_info[:config].resolve!
56
39
  end
40
+ self
57
41
  end
58
42
 
59
43
  #
@@ -68,41 +52,41 @@ module Configliere
68
52
  #
69
53
  def process_argv!
70
54
  super()
71
- if raw_script_name =~ /(\w+)-([\w\-]+)/
72
- self.command_name = $2
73
- else
74
- self.command_name = rest.shift if command?(rest.first)
55
+ base, cmd = script_base_and_command
56
+ if cmd
57
+ self.command_name = cmd.to_sym
58
+ elsif (not rest.empty?) && commands.include?(rest.first.to_sym)
59
+ self.command_name = rest.shift.to_sym
75
60
  end
76
61
  end
77
62
 
78
- # The script name without command appendix if any: For $0 equal to any of
79
- # 'git', 'git-reset', or 'git-cherry-pick', base_script_name is 'git'
80
- #
81
- def base_script_name
82
- raw_script_name.gsub(/-.*/, '')
83
- end
84
-
85
63
  # Usage line
86
64
  def usage
87
- %Q{usage: #{base_script_name} command [...--param=val...]}
65
+ %Q{usage: #{script_base_and_command.first} [command] [...--param=val...]}
88
66
  end
89
67
 
68
+ protected
69
+
90
70
  # Return help on commands.
91
- def dump_command_help
92
- help = ["Available commands"]
93
- help += commands.keys.map(&:to_s).sort.map do |key|
94
- " %-27s %s" % [key.to_s, commands[key][:description]] unless commands[key][:no_help]
71
+ def commands_help
72
+ help = ["\nAvailable commands:"]
73
+ commands.sort_by(&:to_s).each do |cmd, info|
74
+ help << (" %-27s %s" % [cmd, info[:description]]) unless info[:internal]
95
75
  end
96
- help += ["\nRun `#{base_script_name} help COMMAND' for more help on COMMAND"] if command?(:help)
97
- $stderr.puts help.join("\n")
76
+ help << "\nRun `#{script_base_and_command.first} help COMMAND' for more help on COMMAND" if commands.include?(:help)
77
+ help.flatten.join("\n")
78
+ end
79
+
80
+ # The script name without command appendix if any: For $0 equal to any of
81
+ # 'git', 'git-reset', or 'git-cherry-pick', base_script_name is 'git'
82
+ #
83
+ def script_base_and_command
84
+ raw_script_name.split('-', 2)
98
85
  end
99
-
100
86
  end
101
87
 
102
- Param.class_eval do
103
- # include command syntax methods in chain. Since commandline is required
104
- # first at the top of this file, Commands methods sit below
105
- # Commandline methods in the superclass chain.
106
- include Commands
88
+ Param.on_use(:commands) do
89
+ use :commandline
90
+ extend Configliere::Commands
107
91
  end
108
92
  end
@@ -1,4 +1,3 @@
1
- Configliere.use :define
2
1
  module Configliere
3
2
  #
4
3
  # ConfigBlock lets you use pure ruby to change and define settings. Call
@@ -8,6 +7,8 @@ module Configliere
8
7
  # Settings.finally{|c| c.your_mom[:college] = 'went' unless (! c.mom_jokes_allowed) }
9
8
  #
10
9
  module ConfigBlock
10
+ #
11
+ # Define a block of code to run after all other settings are in place.
11
12
  #
12
13
  # @param &block each +finally+ block is called once, in the order it was
13
14
  # defined, when the resolve! method is invoked. +config_block+ resolution
@@ -43,10 +44,11 @@ module Configliere
43
44
  def final_blocks
44
45
  @final_blocks ||= []
45
46
  end
47
+
46
48
  # call each +finally+ config block in the order it was defined
47
49
  def resolve_finally_blocks!
48
50
  final_blocks.each do |block|
49
- block.call()
51
+ (block.arity == 1) ? block.call(self) : block.call()
50
52
  end
51
53
  end
52
54
  end
@@ -1,8 +1,4 @@
1
- require 'yaml'
2
- require 'fileutils'
3
1
  module Configliere
4
- # Where to load params given only a symbol
5
- DEFAULT_CONFIG_FILE = ENV['HOME'].to_s+'/.configliere.yaml' unless defined?(DEFAULT_CONFIG_FILE)
6
2
  # Where to load params given a bare filename
7
3
  DEFAULT_CONFIG_DIR = ENV['HOME'].to_s+'/.configliere' unless defined?(DEFAULT_CONFIG_DIR)
8
4
 
@@ -10,84 +6,66 @@ module Configliere
10
6
  # ConfigFile -- load configuration from a simple YAML file
11
7
  #
12
8
  module ConfigFile
13
- # Load params from disk.
14
- # * file is in YAML format, as a hash of handle => param_hash pairs
15
- # * filename defaults to Configliere::DEFAULT_CONFIG_FILE (~/.configliere, probably)
9
+ # Load params from a YAML file, as a hash of handle => param_hash pairs
16
10
  #
17
- # @option [String] :env
11
+ # @param filename [String] the file to read. If it does not contain a '/',
12
+ # the filename is expanded relative to Configliere::DEFAULT_CONFIG_DIR
13
+ # @param options [Hash]
14
+ # @option options :env [String]
18
15
  # If an :env option is given, only the indicated subhash is merged. This
19
16
  # lets you for example specify production / environment / test settings
20
17
  #
21
- # @returns [Configliere::Params] the Settings object
18
+ # @return [Configliere::Params] the Settings object
22
19
  #
23
20
  # @example
24
- # # Read from config/apey_eye.yaml and use settings appropriate for development/staging/production
25
- # Settings.read(root_path('config/apey_eye.yaml'), :env => (ENV['RACK_ENV'] || 'production'))
21
+ # # Read from ~/.configliere/foo.yaml
22
+ # Settings.read(foo.yaml)
26
23
  #
27
- def read handle, options={}
28
- filename = filename_for_handle(handle)
24
+ # @example
25
+ # # Read from config/foo.yaml and use settings appropriate for development/staging/production
26
+ # Settings.read(App.root.join('config', 'environment.yaml'), :env => ENV['RACK_ENV'])
27
+ #
28
+ # The env option is *not* coerced to_sym, so make sure your key type matches the file's
29
+ def read filename, options={}
30
+ if filename.is_a?(Symbol) then raise Configliere::DeprecatedError, "Loading from a default config file is no longer provided" ; end
31
+ filename = expand_filename(filename)
29
32
  begin
30
- params = YAML.load(File.open(filename)) || {}
33
+ new_data = YAML.load(File.open(filename)) || {}
31
34
  rescue Errno::ENOENT => e
32
35
  warn "Loading empty configliere settings file #{filename}"
33
- params = {}
36
+ new_data = {}
34
37
  end
35
- params = params[handle] if handle.is_a?(Symbol)
36
38
  # Extract the :env (production/development/etc)
37
39
  if options[:env]
38
- params = params[options[:env]] || {}
40
+ new_data = new_data[options[:env]] || {}
39
41
  end
40
- deep_merge! params
42
+ deep_merge! new_data
41
43
  self
42
44
  end
43
45
 
44
46
  # save to disk.
45
47
  # * file is in YAML format, as a hash of handle => param_hash pairs
46
48
  # * filename defaults to Configliere::DEFAULT_CONFIG_FILE (~/.configliere, probably)
47
- def save! handle
48
- filename = filename_for_handle(handle)
49
- if handle.is_a?(Symbol)
50
- ConfigFile.merge_into_yaml_file filename, handle, self.export
51
- else
52
- ConfigFile.write_yaml_file filename, self.export
53
- end
54
- end
55
-
56
- protected
57
-
58
- # form suitable for serialization to disk
59
- # (e.g. the encryption done in configliere/encrypted)
60
- def export
61
- super.to_hash
62
- end
63
-
64
- def self.write_yaml_file filename, hsh
49
+ def save! filename
50
+ filename = expand_filename(filename)
51
+ hsh = self.export.to_hash
65
52
  FileUtils.mkdir_p(File.dirname(filename))
66
53
  File.open(filename, 'w'){|f| f << YAML.dump(hsh) }
67
54
  end
68
55
 
69
- def self.merge_into_yaml_file filename, handle, params
70
- begin
71
- all_settings = YAML.load(File.open(filename)) || {}
72
- rescue Errno::ENOENT => e;
73
- all_settings = {}
74
- end
75
- all_settings[handle] = params
76
- write_yaml_file filename, all_settings
77
- end
56
+ protected
78
57
 
79
- def filename_for_handle handle
80
- case
81
- when handle.is_a?(Symbol) then Configliere::DEFAULT_CONFIG_FILE
82
- when handle.to_s.include?('/') then File.expand_path(handle)
83
- else File.join(Configliere::DEFAULT_CONFIG_DIR, handle)
58
+ def expand_filename filename
59
+ if filename.to_s.include?('/')
60
+ File.expand_path(filename)
61
+ else
62
+ File.expand_path(File.join(Configliere::DEFAULT_CONFIG_DIR, filename))
84
63
  end
85
64
  end
86
-
87
65
  end
88
66
 
67
+ # ConfigFile is included by default
89
68
  Param.class_eval do
90
- # include read / save operations
91
69
  include ConfigFile
92
70
  end
93
71
  end
@@ -1,5 +1,6 @@
1
- require 'openssl'
2
- require 'digest/sha2'
1
+ require 'openssl' # for encryption
2
+ require 'digest/sha2' # for encryption
3
+ require "base64" # base64-encode the binary encrypted string
3
4
  module Configliere
4
5
  #
5
6
  # Encrypt and decrypt values in configliere stores
@@ -22,7 +23,7 @@ module Configliere
22
23
  cipher.iv = iv = cipher.random_iv
23
24
  ciphertext = cipher.update(plaintext)
24
25
  ciphertext << cipher.final
25
- combine_iv_and_ciphertext(iv, ciphertext)
26
+ Base64.encode64(combine_iv_and_ciphertext(iv, ciphertext))
26
27
  end
27
28
  #
28
29
  # Decrypt the given string, using the key and iv supplied
@@ -31,7 +32,8 @@ module Configliere
31
32
  # @param [String] encrypt_pass secret passphrase to decrypt with
32
33
  # @return [String] the decrypted plaintext
33
34
  #
34
- def self.decrypt iv_and_ciphertext, encrypt_pass, options={}
35
+ def self.decrypt enc_ciphertext, encrypt_pass, options={}
36
+ iv_and_ciphertext = Base64.decode64(enc_ciphertext)
35
37
  cipher = new_cipher :decrypt, encrypt_pass, options
36
38
  cipher.iv, ciphertext = separate_iv_and_ciphertext(cipher, iv_and_ciphertext)
37
39
  plaintext = cipher.update(ciphertext)
@@ -64,7 +66,8 @@ module Configliere
64
66
 
65
67
  # Convert the encrypt_pass passphrase into the key used for encryption
66
68
  def self.encrypt_key encrypt_pass, options={}
67
- raise 'Blank encryption password!' if encrypt_pass.blank?
69
+ encrypt_pass = encrypt_pass.to_s
70
+ raise 'Missing encryption password!' if encrypt_pass.empty?
68
71
  # this provides the required 256 bits of key for the aes-256-cbc cipher
69
72
  Digest::SHA256.digest(encrypt_pass)
70
73
  end
@@ -0,0 +1,368 @@
1
+ #
2
+ # A magical hash: allows deep-nested access and proper merging of settings from
3
+ # different sources
4
+ #
5
+ class DeepHash < Hash
6
+
7
+ # @param constructor<Object>
8
+ # The default value for the DeepHash. Defaults to an empty hash.
9
+ # If constructor is a Hash, adopt its values.
10
+ def initialize(constructor = {})
11
+ if constructor.is_a?(Hash)
12
+ super()
13
+ update(constructor) unless constructor.empty?
14
+ else
15
+ super(constructor)
16
+ end
17
+ end
18
+
19
+ unless method_defined?(:regular_writer) then alias_method :regular_writer, :[]= ; end
20
+ unless method_defined?(:regular_update) then alias_method :regular_update, :update ; end
21
+
22
+ # @param key<Object> The key to check for. This will be run through convert_key.
23
+ #
24
+ # @return [Boolean] True if the key exists in the mash.
25
+ def key?(key)
26
+ attr = convert_key(key)
27
+ if attr.is_a?(Array)
28
+ fk = attr.shift
29
+ attr = attr.first if attr.length == 1
30
+ super(fk) && (self[fk].key?(attr)) rescue nil
31
+ else
32
+ super(attr)
33
+ end
34
+ end
35
+
36
+ # def include? def has_key? def member?
37
+ alias_method :include?, :key?
38
+ alias_method :has_key?, :key?
39
+ alias_method :member?, :key?
40
+
41
+ # @param key<Object> The key to fetch. This will be run through convert_key.
42
+ # @param *extras<Array> Default value.
43
+ #
44
+ # @return [Object] The value at key or the default value.
45
+ def fetch(key, *extras)
46
+ super(convert_key(key), *extras)
47
+ end
48
+
49
+ # @param *indices<Array>
50
+ # The keys to retrieve values for. These will be run through +convert_key+.
51
+ #
52
+ # @return [Array] The values at each of the provided keys
53
+ def values_at(*indices)
54
+ indices.collect{|key| self[convert_key(key)]}
55
+ end
56
+
57
+ # @param key<Object>
58
+ # The key to delete from the DeepHash.
59
+ def delete attr
60
+ attr = convert_key(attr)
61
+ attr.is_a?(Array) ? deep_delete(*attr) : super(attr)
62
+ end
63
+
64
+ # @return [Hash] converts to a plain hash.
65
+ def to_hash
66
+ Hash.new(default).merge(self)
67
+ end
68
+
69
+ # @param hash<Hash> The hash to merge with the deep_hash.
70
+ #
71
+ # @return [DeepHash] A new deep_hash with the hash values merged in.
72
+ def merge(hash, &block)
73
+ self.dup.update(hash, &block)
74
+ end
75
+
76
+ alias_method :merge!, :update
77
+
78
+ # @param other_hash<Hash>
79
+ # A hash to update values in the deep_hash with. The keys and the values will be
80
+ # converted to DeepHash format.
81
+ #
82
+ # @return [DeepHash] The updated deep_hash.
83
+ def update(other_hash, &block)
84
+ deep_hash = self.class.new
85
+ other_hash.each_pair do |key, value|
86
+ val = convert_value(value)
87
+ deep_hash[key] = val
88
+ end
89
+ regular_update(deep_hash, &block)
90
+ end
91
+
92
+ # Return a new hash with all keys converted to symbols, as long as
93
+ # they respond to +to_sym+.
94
+ def symbolize_keys
95
+ dup.symbolize_keys!
96
+ end unless method_defined?(:symbolize_keys)
97
+
98
+ # Used to provide the same interface as Hash.
99
+ #
100
+ # @return [DeepHash] This deep_hash unchanged.
101
+ def symbolize_keys!; self end
102
+
103
+ #
104
+ # remove all key-value pairs where the value is nil
105
+ #
106
+ def compact
107
+ reject{|key,val| val.nil? }
108
+ end
109
+ #
110
+ # Replace the hash with its compacted self
111
+ #
112
+ def compact!
113
+ replace(compact)
114
+ end
115
+ # Slice a hash to include only the given keys. This is useful for
116
+ # limiting an options hash to valid keys before passing to a method:
117
+ #
118
+ # def search(criteria = {})
119
+ # assert_valid_keys(:mass, :velocity, :time)
120
+ # end
121
+ #
122
+ # search(options.slice(:mass, :velocity, :time))
123
+ #
124
+ # If you have an array of keys you want to limit to, you should splat them:
125
+ #
126
+ # valid_keys = [:mass, :velocity, :time]
127
+ # search(options.slice(*valid_keys))
128
+ def slice(*keys)
129
+ keys = keys.map!{|key| convert_key(key) } if respond_to?(:convert_key)
130
+ hash = self.class.new
131
+ keys.each { |k| hash[k] = self[k] if has_key?(k) }
132
+ hash
133
+ end unless method_defined?(:slice)
134
+
135
+ # Replaces the hash with only the given keys.
136
+ # Returns a hash containing the removed key/value pairs
137
+ # @example
138
+ # hsh = {:a => 1, :b => 2, :c => 3, :d => 4}
139
+ # hsh.slice!(:a, :b)
140
+ # # => {:c => 3, :d =>4}
141
+ # hsh
142
+ # # => {:a => 1, :b => 2}
143
+ def slice!(*keys)
144
+ keys = keys.map!{|key| convert_key(key) } if respond_to?(:convert_key)
145
+ omit = slice(*self.keys - keys)
146
+ hash = slice(*keys)
147
+ replace(hash)
148
+ omit
149
+ end unless method_defined?(:slice!)
150
+
151
+ # Removes the given keys from the hash
152
+ # Returns a hash containing the removed key/value pairs
153
+ #
154
+ # @example
155
+ # hsh = {:a => 1, :b => 2, :c => 3, :d => 4}
156
+ # hsh.extract!(:a, :b)
157
+ # # => {:a => 1, :b => 2}
158
+ # hsh
159
+ # # => {:c => 3, :d =>4}
160
+ def extract!(*keys)
161
+ result = self.class.new
162
+ keys.each{|key| result[key] = delete(key) }
163
+ result
164
+ end unless method_defined?(:extract!)
165
+
166
+ # Allows for reverse merging two hashes where the keys in the calling hash take precedence over those
167
+ # in the <tt>other_hash</tt>. This is particularly useful for initializing an option hash with default values:
168
+ #
169
+ # def setup(options = {})
170
+ # options.reverse_merge! :size => 25, :velocity => 10
171
+ # end
172
+ #
173
+ # Using <tt>merge</tt>, the above example would look as follows:
174
+ #
175
+ # def setup(options = {})
176
+ # { :size => 25, :velocity => 10 }.merge(options)
177
+ # end
178
+ #
179
+ # The default <tt>:size</tt> and <tt>:velocity</tt> are only set if the +options+ hash passed in doesn't already
180
+ # have the respective key.
181
+ def reverse_merge(other_hash)
182
+ other_hash.merge(self)
183
+ end unless method_defined?(:reverse_merge)
184
+
185
+ # Performs the opposite of <tt>merge</tt>, with the keys and values from the first hash taking precedence over the second.
186
+ # Modifies the receiver in place.
187
+ def reverse_merge!(other_hash)
188
+ merge!( other_hash ){|k,o,n| o }
189
+ end unless method_defined?(:reverse_merge!)
190
+
191
+ # Validate all keys in a hash match *valid keys, raising ArgumentError on a mismatch.
192
+ # Note that keys are NOT treated indifferently, meaning if you use strings for keys but assert symbols
193
+ # as keys, this will fail.
194
+ #
195
+ # ==== Examples
196
+ # { :name => "Rob", :years => "28" }.assert_valid_keys(:name, :age) # => raises "ArgumentError: Unknown key(s): years"
197
+ # { :name => "Rob", :age => "28" }.assert_valid_keys("name", "age") # => raises "ArgumentError: Unknown key(s): name, age"
198
+ # { :name => "Rob", :age => "28" }.assert_valid_keys(:name, :age) # => passes, raises nothing
199
+ def assert_valid_keys(*valid_keys)
200
+ unknown_keys = keys - [valid_keys].flatten
201
+ raise(ArgumentError, "Unknown key(s): #{unknown_keys.join(", ")}") unless unknown_keys.empty?
202
+ end unless method_defined?(:assert_valid_keys)
203
+
204
+ # Sets a member value.
205
+ #
206
+ # Given a deep key (one that contains '.'), uses it as a chain of hash
207
+ # memberships. Otherwise calls the normal hash member setter
208
+ #
209
+ # @example
210
+ # foo = DeepHash.new :hi => 'there'
211
+ # foo['howdy.doody'] = 3
212
+ # foo # => { :hi => 'there', :howdy => { :doody => 3 } }
213
+ #
214
+ def []= attr, val
215
+ attr = convert_key(attr)
216
+ val = convert_value(val)
217
+ attr.is_a?(Array) ? deep_set(*(attr | [val])) : super(attr, val)
218
+ end
219
+
220
+
221
+ # Gets a member value.
222
+ #
223
+ # Given a deep key (one that contains '.'), uses it as a chain of hash
224
+ # memberships. Otherwise calls the normal hash member getter
225
+ #
226
+ # @example
227
+ # foo = DeepHash.new({ :hi => 'there', :howdy => { :doody => 3 } })
228
+ # foo['howdy.doody'] # => 3
229
+ # foo['hi'] # => 'there'
230
+ # foo[:hi] # => 'there'
231
+ #
232
+ def [] attr
233
+ attr = convert_key(attr)
234
+ raise if (attr == [:made])
235
+ attr.is_a?(Array) ? deep_get(*attr) : super(attr)
236
+ end
237
+
238
+ # lambda for recursive merges
239
+ ::DeepHash::DEEP_MERGER = proc do |key,v1,v2|
240
+ if (v1.respond_to?(:update) && v2.respond_to?(:update))
241
+ v1.update(v2.reject{|kk,vv| vv.nil? }, &DeepHash::DEEP_MERGER)
242
+ elsif v2.nil?
243
+ v1
244
+ else
245
+ v2
246
+ end
247
+ end unless defined?(::DeepHash::DEEP_MERGER)
248
+
249
+ #
250
+ # Merge hashes recursively.
251
+ # Nothing special happens to array values
252
+ #
253
+ # x = { :subhash => { 1 => :val_from_x, 222 => :only_in_x, 333 => :only_in_x }, :scalar => :scalar_from_x}
254
+ # y = { :subhash => { 1 => :val_from_y, 999 => :only_in_y }, :scalar => :scalar_from_y }
255
+ # x.deep_merge y
256
+ # => {:subhash=>{1=>:val_from_y, 222=>:only_in_x, 333=>:only_in_x, 999=>:only_in_y}, :scalar=>:scalar_from_y}
257
+ # y.deep_merge x
258
+ # => {:subhash=>{1=>:val_from_x, 222=>:only_in_x, 333=>:only_in_x, 999=>:only_in_y}, :scalar=>:scalar_from_x}
259
+ #
260
+ # Nil values always lose.
261
+ #
262
+ # x = {:subhash=>{:nil_in_x=>nil, 1=>:val1,}, :nil_in_x=>nil}
263
+ # y = {:subhash=>{:nil_in_x=>5}, :nil_in_x=>5}
264
+ # y.deep_merge x
265
+ # => {:subhash=>{1=>:val1, :nil_in_x=>5}, :nil_in_x=>5}
266
+ # x.deep_merge y
267
+ # => {:subhash=>{1=>:val1, :nil_in_x=>5}, :nil_in_x=>5}
268
+ #
269
+ def deep_merge hsh2
270
+ merge hsh2, &DeepHash::DEEP_MERGER
271
+ end
272
+
273
+ def deep_merge! hsh2
274
+ update hsh2, &DeepHash::DEEP_MERGER
275
+ self
276
+ end
277
+
278
+ #
279
+ # Treat hash as tree of hashes:
280
+ #
281
+ # x = { 1 => :val, :subhash => { 1 => :val1 } }
282
+ # x.deep_set(:subhash, :cat, :hat)
283
+ # # => { 1 => :val, :subhash => { 1 => :val1, :cat => :hat } }
284
+ # x.deep_set(:subhash, 1, :newval)
285
+ # # => { 1 => :val, :subhash => { 1 => :newval, :cat => :hat } }
286
+ #
287
+ #
288
+ def deep_set *args
289
+ val = args.pop
290
+ last_key = args.pop
291
+ # dig down to last subtree (building out if necessary)
292
+ hsh = self
293
+ args.each do |key|
294
+ hsh.regular_writer(key, self.class.new) unless hsh.has_key?(key)
295
+ hsh = hsh[key]
296
+ end
297
+ # set leaf value
298
+ hsh[last_key] = val
299
+ end
300
+
301
+ #
302
+ # Treat hash as tree of hashes:
303
+ #
304
+ # x = { 1 => :val, :subhash => { 1 => :val1 } }
305
+ # x.deep_get(:subhash, 1)
306
+ # # => :val
307
+ # x.deep_get(:subhash, 2)
308
+ # # => nil
309
+ # x.deep_get(:subhash, 2, 3)
310
+ # # => nil
311
+ # x.deep_get(:subhash, 2)
312
+ # # => nil
313
+ #
314
+ def deep_get *args
315
+ last_key = args.pop
316
+ # dig down to last subtree (building out if necessary)
317
+ hsh = args.inject(self){|h, k| h[k] || self.class.new }
318
+ # get leaf value
319
+ hsh[last_key]
320
+ end
321
+
322
+ #
323
+ # Treat hash as tree of hashes:
324
+ #
325
+ # x = { 1 => :val, :subhash => { 1 => :val1, 2 => :val2 } }
326
+ # x.deep_delete(:subhash, 1)
327
+ # #=> :val
328
+ # x
329
+ # #=> { 1 => :val, :subhash => { 2 => :val2 } }
330
+ #
331
+ def deep_delete *args
332
+ last_key = args.pop
333
+ last_hsh = args.empty? ? self : (deep_get(*args)||{})
334
+ last_hsh.delete(last_key)
335
+ end
336
+
337
+ protected
338
+ # @attr key<Object> The key to convert.
339
+ #
340
+ # @attr [Object]
341
+ # The converted key. A dotted attr ('moon.cheese.type') becomes
342
+ # an array of sequential keys for deep_set and deep_get
343
+ #
344
+ # @private
345
+ def convert_key(attr)
346
+ case
347
+ when attr.to_s.include?('.') then attr.to_s.split(".").map{|sub_attr| sub_attr.to_sym }
348
+ when attr.is_a?(String) then attr.to_sym
349
+ else attr
350
+ end
351
+ end
352
+
353
+
354
+ # @param value<Object> The value to convert.
355
+ #
356
+ # @return [Object]
357
+ # The converted value. A Hash or an Array of hashes, will be converted to
358
+ # their DeepHash equivalents.
359
+ #
360
+ # @private
361
+ def convert_value(value)
362
+ if value.class == Hash then self.class.new(value)
363
+ elsif value.is_a?(Array) then value.collect{|e| convert_value(e) }
364
+ else value
365
+ end
366
+ end
367
+
368
+ end