configliere 0.3.4 → 0.4.4

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.
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