fpm-cookery 0.32.0 → 0.33.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/.travis.yml +5 -2
  4. data/CHANGELOG.md +19 -0
  5. data/Rakefile +34 -0
  6. data/docs/index.rst +1 -0
  7. data/docs/pages/using-hiera.rst +285 -0
  8. data/fpm-cookery.gemspec +6 -1
  9. data/lib/fpm/cookery/book.rb +29 -2
  10. data/lib/fpm/cookery/book_hook.rb +1 -0
  11. data/lib/fpm/cookery/chain_packager.rb +4 -2
  12. data/lib/fpm/cookery/cli.rb +45 -5
  13. data/lib/fpm/cookery/config.rb +2 -1
  14. data/lib/fpm/cookery/environment.rb +17 -8
  15. data/lib/fpm/cookery/exceptions.rb +3 -1
  16. data/lib/fpm/cookery/facts.rb +50 -35
  17. data/lib/fpm/cookery/hiera.rb +35 -0
  18. data/lib/fpm/cookery/hiera/defaults.rb +50 -0
  19. data/lib/fpm/cookery/hiera/scope.rb +35 -0
  20. data/lib/fpm/cookery/inheritable_attr.rb +222 -0
  21. data/lib/fpm/cookery/log/hiera.rb +21 -0
  22. data/lib/fpm/cookery/omnibus_packager.rb +4 -2
  23. data/lib/fpm/cookery/package/package.rb +1 -0
  24. data/lib/fpm/cookery/package/version.rb +11 -4
  25. data/lib/fpm/cookery/packager.rb +13 -11
  26. data/lib/fpm/cookery/recipe.rb +167 -105
  27. data/lib/fpm/cookery/source.rb +6 -8
  28. data/lib/fpm/cookery/source_handler.rb +18 -3
  29. data/lib/fpm/cookery/source_handler/curl.rb +2 -2
  30. data/lib/fpm/cookery/source_handler/directory.rb +10 -11
  31. data/lib/fpm/cookery/source_handler/noop.rb +1 -2
  32. data/lib/fpm/cookery/source_handler/svn.rb +1 -1
  33. data/lib/fpm/cookery/version.rb +1 -1
  34. data/lib/hiera/fpm_cookery_logger.rb +12 -0
  35. data/recipes/redis/config/common.yaml +11 -0
  36. data/recipes/redis/config/git_2.4.2_tag.yaml +4 -0
  37. data/recipes/redis/config/git_2.4.yaml +4 -0
  38. data/recipes/redis/config/git_sha_072a905.yaml +4 -0
  39. data/recipes/redis/config/svn_r2400.yaml +4 -0
  40. data/recipes/redis/config/svn_trunk.yaml +3 -0
  41. data/recipes/redis/recipe.rb +2 -27
  42. data/spec/book_spec.rb +34 -0
  43. data/spec/config_spec.rb +19 -0
  44. data/spec/environment_spec.rb +37 -0
  45. data/spec/facts_spec.rb +54 -31
  46. data/spec/fixtures/hiera_config/CentOS.yaml +1 -0
  47. data/spec/fixtures/hiera_config/common.yaml +12 -0
  48. data/spec/fixtures/hiera_config/custom.yaml +3 -0
  49. data/spec/fixtures/hiera_config/rpm.yaml +12 -0
  50. data/spec/hiera_spec.rb +158 -0
  51. data/spec/inheritable_attr_spec.rb +202 -0
  52. data/spec/package_dir_spec.rb +37 -0
  53. data/spec/package_maintainer_spec.rb +4 -1
  54. data/spec/package_version_spec.rb +50 -0
  55. data/spec/path_spec.rb +20 -0
  56. data/spec/recipe_spec.rb +161 -56
  57. data/spec/source_integrity_check_spec.rb +7 -6
  58. data/spec/spec_helper.rb +14 -0
  59. data/spec/support/shared_context.rb +71 -0
  60. metadata +108 -4
@@ -10,6 +10,7 @@ module FPM
10
10
  module ClassMethods
11
11
  def inherited(klass)
12
12
  FPM::Cookery::Book.instance.add_recipe_class(klass)
13
+ FPM::Cookery::Book.instance.inject_class_methods!(klass)
13
14
  end
14
15
  end
15
16
  end
@@ -1,5 +1,6 @@
1
1
  require 'fpm/cookery/packager'
2
2
  require 'fpm/cookery/omnibus_packager'
3
+ require 'fpm/cookery/exceptions'
3
4
 
4
5
  module FPM
5
6
  module Cookery
@@ -20,8 +21,9 @@ module FPM
20
21
  recipe.chain_recipes.each do |name|
21
22
  recipe_file = build_recipe_file_path(name)
22
23
  unless File.exists?(recipe_file)
23
- Log.fatal "Cannot find a recipe for #{name} at #{recipe_file}"
24
- exit 1
24
+ error_message = "Cannot find a recipe for #{name} at #{recipe_file}"
25
+ Log.fatal error_message
26
+ raise Error::ExecutionFailure, error_message
25
27
  end
26
28
  FPM::Cookery::Book.instance.load_recipe(recipe_file, config) do |dep_recipe|
27
29
  depPackager = FPM::Cookery::Packager.new(dep_recipe, config.to_hash)
@@ -27,10 +27,20 @@ module FPM
27
27
  :attribute_name => 'pkg_dir'
28
28
  option '--cache-dir', 'DIR', 'directory for downloaded sources',
29
29
  :attribute_name => 'cache_dir'
30
+ option '--data-dir', 'DIR', 'directory for Hiera data files',
31
+ :attribute_name => 'data_dir'
32
+ option '--hiera-config', 'FILE', 'Hiera configuration file',
33
+ :attribute_name => 'hiera_config'
30
34
  option '--skip-package', :flag, 'do not call FPM to build the package',
31
35
  :attribute_name => 'skip_package'
36
+ option '--vendor-delimiter', 'DELIMITER', 'vendor delimiter for version string',
37
+ :attribute_name => 'vendor_delimiter'
32
38
 
33
39
  class Command < self
40
+ def self.add_recipe_parameter!
41
+ parameter '[RECIPE]', 'the recipe file', :default => 'recipe.rb'
42
+ end
43
+
34
44
  def recipe_file
35
45
  file = File.expand_path(recipe)
36
46
 
@@ -76,6 +86,8 @@ module FPM
76
86
 
77
87
  exec(config, recipe, packager)
78
88
  end
89
+ rescue Error::ExecutionFailure, Error::Misconfiguration
90
+ exit 1
79
91
  end
80
92
 
81
93
  def show_version
@@ -102,7 +114,7 @@ module FPM
102
114
  end
103
115
 
104
116
  class PackageCmd < Command
105
- parameter '[RECIPE]', 'the recipe file', :default => 'recipe.rb'
117
+ add_recipe_parameter!
106
118
 
107
119
  def exec(config, recipe, packager)
108
120
  if recipe.omnibus_package == true
@@ -116,7 +128,7 @@ module FPM
116
128
  end
117
129
 
118
130
  class CleanCmd < Command
119
- parameter '[RECIPE]', 'the recipe file', :default => 'recipe.rb'
131
+ add_recipe_parameter!
120
132
 
121
133
  def exec(config, recipe, packager)
122
134
  packager.cleanup
@@ -124,7 +136,7 @@ module FPM
124
136
  end
125
137
 
126
138
  class InstallDepsCmd < Command
127
- parameter '[RECIPE]', 'the recipe file', :default => 'recipe.rb'
139
+ add_recipe_parameter!
128
140
 
129
141
  def exec(config, recipe, packager)
130
142
  packager.install_deps
@@ -132,7 +144,7 @@ module FPM
132
144
  end
133
145
 
134
146
  class InstallBuildDepsCmd < Command
135
- parameter '[RECIPE]', 'the recipe file', :default => 'recipe.rb'
147
+ add_recipe_parameter!
136
148
 
137
149
  def exec(config, recipe, packager)
138
150
  if recipe.omnibus_package == true
@@ -146,13 +158,40 @@ module FPM
146
158
  end
147
159
 
148
160
  class ShowDepsCmd < Command
149
- parameter '[RECIPE]', 'the recipe file', :default => 'recipe.rb'
161
+ add_recipe_parameter!
150
162
 
151
163
  def exec(config, recipe, packager)
152
164
  puts recipe.depends_all.join(' ')
153
165
  end
154
166
  end
155
167
 
168
+ class InspectCmd < Command
169
+ add_recipe_parameter!
170
+
171
+ option ['-F', '--format'], 'TEMPLATE', 'ERB template string'
172
+ option '--terse', :flag, 'show recipe data in compact form'
173
+
174
+ self.description = <<DESCRIPTION
175
+ With --format, templates and prints an ERB string with recipe attributes.
176
+
177
+ Example:
178
+
179
+ # Given a recipe with name "foo", version "1.1", revision "12":
180
+ $ fpm-cook inspect -t rpm --format "<%= name %>-<%= version %>-<%= revision %>.rpm"
181
+ foo-1.1-12.rpm
182
+
183
+ Without --format, prints a JSON representation of the recipe.
184
+ DESCRIPTION
185
+
186
+ def exec(config, recipe, packager)
187
+ unless format.nil?
188
+ puts recipe.template(format)
189
+ else
190
+ puts terse? ? recipe.to_json : recipe.to_pretty_json
191
+ end
192
+ end
193
+ end
194
+
156
195
  self.default_subcommand = 'package'
157
196
 
158
197
  subcommand 'package', 'builds the package', PackageCmd
@@ -160,6 +199,7 @@ module FPM
160
199
  subcommand 'install-deps', 'installs build and runtime dependencies', InstallDepsCmd
161
200
  subcommand 'install-build-deps', 'installs build dependencies', InstallBuildDepsCmd
162
201
  subcommand 'show-deps', 'show build and runtime dependencies', ShowDepsCmd
202
+ subcommand 'inspect', 'inspect recipe attributes', InspectCmd
163
203
  end
164
204
  end
165
205
  end
@@ -7,7 +7,8 @@ module FPM
7
7
  ATTRIBUTES = [
8
8
  :color, :debug, :target, :platform, :maintainer, :vendor,
9
9
  :skip_package, :keep_destdir, :dependency_check, :quiet,
10
- :tmp_root, :pkg_dir, :cache_dir
10
+ :tmp_root, :pkg_dir, :cache_dir, :data_dir, :hiera_config,
11
+ :vendor_delimiter
11
12
  ].freeze
12
13
 
13
14
  DEFAULTS = {
@@ -2,27 +2,36 @@ require 'fpm/cookery/log'
2
2
 
3
3
  module FPM
4
4
  module Cookery
5
- class Environment
5
+ class Environment < Hash
6
6
  REMOVALS = %w(
7
7
  BUNDLE_GEMFILE RUBYOPT BUNDLE_BIN_PATH GEM_HOME GEM_PATH
8
8
  ).freeze
9
9
 
10
- def initialize
11
- @env = {}
10
+ # Coerce keys and values to +String+s on creation
11
+ def self.[](h = {})
12
+ super(Hash[h.map { |k, v| [k.to_s, v.to_s] }])
12
13
  end
13
14
 
14
15
  def [](key)
15
- @env[key.to_s]
16
+ super(key.to_s)
16
17
  end
17
18
 
18
19
  def []=(key, value)
19
20
  if value.nil?
20
- @env.delete(key.to_s)
21
+ delete(key.to_s)
21
22
  else
22
- @env[key.to_s] = value.to_s
23
+ super(key.to_s, value.to_s)
23
24
  end
24
25
  end
25
26
 
27
+ def merge(other)
28
+ super(self.class[other])
29
+ end
30
+
31
+ def merge!(other)
32
+ super(self.class[other])
33
+ end
34
+
26
35
  def with_clean
27
36
  saved_env = ENV.to_hash
28
37
 
@@ -31,7 +40,7 @@ module FPM
31
40
  Log.debug("Removing '#{var}' => '#{value}' from environment")
32
41
  end
33
42
 
34
- @env.each do |k, v|
43
+ each do |k, v|
35
44
  Log.debug("Adding '#{k}' => '#{v}' to environment")
36
45
  ENV[k] = v
37
46
  end
@@ -42,7 +51,7 @@ module FPM
42
51
  end
43
52
 
44
53
  def to_hash
45
- @env.dup
54
+ Hash[self]
46
55
  end
47
56
  end
48
57
  end
@@ -1,7 +1,9 @@
1
1
  module FPM
2
2
  module Cookery
3
3
  class Error < StandardError
4
- MethodNotImplemented = Class.new(self)
4
+ MethodNotImplemented = Class.new(self)
5
+ ExecutionFailure = Class.new(self)
6
+ Misconfiguration = Class.new(self)
5
7
 
6
8
  class InvalidConfigKey < self
7
9
  attr_accessor :invalid_keys
@@ -3,41 +3,56 @@ require 'facter'
3
3
  module FPM
4
4
  module Cookery
5
5
  class Facts
6
- def self.arch
7
- @arch ||= Facter.fact(:architecture).value.downcase.to_sym
8
- end
9
-
10
- def self.platform
11
- @platform ||= Facter.fact(:operatingsystem).value.downcase.to_sym
12
- end
13
-
14
- def self.platform=(value)
15
- @platform = value.downcase.to_sym
16
- end
17
-
18
- def self.osrelease
19
- @osrelease ||= Facter.fact(:operatingsystemrelease).value
20
- end
21
-
22
- def self.osmajorrelease
23
- @osmajorrelease ||= Facter.fact(:operatingsystemmajrelease).value
24
- end
25
-
26
- def self.target
27
- @target ||= case platform
28
- when :centos, :redhat, :fedora, :amazon,
29
- :scientific, :oraclelinux then :rpm
30
- when :debian, :ubuntu then :deb
31
- when :darwin then :osxpkg
32
- end
33
- end
34
-
35
- def self.target=(value)
36
- @target = value.to_sym
37
- end
38
-
39
- def self.reset!
40
- instance_variables.each {|v| instance_variable_set(v, nil) }
6
+ class << self
7
+ def arch
8
+ @arch ||= value(:architecture)
9
+ end
10
+
11
+ def platform
12
+ @platform ||= value(:operatingsystem)
13
+ end
14
+
15
+ def platform=(value)
16
+ @platform = value.downcase.to_sym
17
+ end
18
+
19
+ def osrelease
20
+ @osrelease ||= value(:operatingsystemrelease, false)
21
+ end
22
+
23
+ def lsbcodename
24
+ @lsbcodename ||= value(:lsbcodename)
25
+ end
26
+
27
+ def osmajorrelease
28
+ @osmajorrelease ||= value(:operatingsystemmajrelease, false)
29
+ end
30
+
31
+ def target
32
+ @target ||= case platform
33
+ when :centos, :redhat, :fedora, :amazon,
34
+ :scientific, :oraclelinux then :rpm
35
+ when :debian, :ubuntu then :deb
36
+ when :darwin then :osxpkg
37
+ when :alpine then :apk
38
+ end
39
+ end
40
+
41
+ def target=(value)
42
+ @target = value.to_sym
43
+ end
44
+
45
+ def reset!
46
+ instance_variables.each {|v| instance_variable_set(v, nil) }
47
+ end
48
+
49
+ private
50
+
51
+ def value(fact_name, symbolize = true)
52
+ v = Facter.value(fact_name)
53
+ return v if v.nil? or !symbolize
54
+ return v.downcase.to_sym
55
+ end
41
56
  end
42
57
  end
43
58
  end
@@ -0,0 +1,35 @@
1
+ require 'hiera'
2
+ require 'fpm/cookery/hiera/defaults'
3
+ require 'fpm/cookery/hiera/scope'
4
+ require 'fpm/cookery/log/hiera'
5
+
6
+ module FPM
7
+ module Cookery
8
+ # Implement Hiera lookups and interpolation for recipes
9
+ module Hiera
10
+ # +Hiera+ subclass that wraps a recipe class
11
+ class Instance < ::Hiera
12
+ include FPM::Cookery::Hiera::Defaults
13
+
14
+ attr_reader :recipe, :scope
15
+
16
+ # Expects a recipe class and a hash containing one key, +:config+.
17
+ def initialize(recipe, options = {})
18
+ @recipe = recipe
19
+ @scope = Scope.new(recipe)
20
+
21
+ # For some reason, +Hiera+'s constructor expects a hash with just the
22
+ # one key.
23
+ super({ :config => hiera_config(options) })
24
+ end
25
+
26
+ # Provides a default scope, and attempts to look up the key both as a
27
+ # string and as a symbol.
28
+ def lookup(key, default = nil, scope = self.scope, *rest)
29
+ super(key.to_sym, default, scope, *rest) || super(key.to_s, default, scope, *rest)
30
+ end
31
+ alias_method :[], :lookup
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,50 @@
1
+ module FPM
2
+ module Cookery
3
+ module Hiera
4
+ module Defaults
5
+ module_function
6
+
7
+ # This will result in Hiera using the +Hiera::Fpm_cookery_logger+ class
8
+ # for logging.
9
+ def hiera_logger
10
+ 'fpm_cookery'
11
+ end
12
+
13
+ # Sets the default search hierarchy. +Hiera+ will look for files
14
+ # matching +"#{ENV['FPM_ENV']}.yaml"+, etc.
15
+ # Note: the including class is expected to define a +recipe+ method
16
+ # that responds to +platform+ and +target+.
17
+ def hiera_hierarchy
18
+ ['common']
19
+ end
20
+
21
+ # Default to attempting lookups using both +.yaml+ and +.json+ files.
22
+ def hiera_backends
23
+ [:yaml, :json]
24
+ end
25
+
26
+ def hiera_datadir
27
+ File.join(Dir.getwd, 'config')
28
+ end
29
+
30
+ # Sets default values for the +{:config => { ... }}+ options hash
31
+ # passed to the +Hiera+ constructor, merging in any options from the
32
+ # caller.
33
+ def hiera_config(options = {})
34
+ # Hiera accepts a path to a configuration file or a hash; short
35
+ # circuit if it's the former.
36
+ return options[:config] unless options[:config].is_a?(Hash)
37
+
38
+ {
39
+ :logger => hiera_logger,
40
+ :hierarchy => hiera_hierarchy,
41
+ :yaml => { :datadir => hiera_datadir },
42
+ :json => { :datadir => hiera_datadir },
43
+ :backends => hiera_backends
44
+ }.merge options[:config] || {}
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+
@@ -0,0 +1,35 @@
1
+ require 'facter'
2
+ require 'fpm/cookery/facts'
3
+
4
+ module FPM
5
+ module Cookery
6
+ module Hiera
7
+ # Wraps a recipe class, adding a +[]+ method so that it can be used as a
8
+ # +Hiera+ scope.
9
+ class Scope
10
+ attr_reader :recipe
11
+
12
+ def initialize(recipe)
13
+ @recipe = recipe
14
+ end
15
+
16
+ # Allow Hiera to perform +%{scope("key")}+ interpolations using data
17
+ # from the recipe class, +FPM::Cookery::Facts+, and +Facter+. Expects
18
+ # +name+ to be a method name or +Facter+ fact name. Returns the result
19
+ # of the lookup. Will be +nil+ if lookup failed to fetch a result.
20
+ def [](name)
21
+ [recipe, FPM::Cookery::Facts].each do |source|
22
+ if source.respond_to?(name)
23
+ return source.send(name)
24
+ end
25
+ end
26
+
27
+ # As a backup, try to retrieve it from +Facter+.
28
+ unless (result = Facter[name]).nil?
29
+ result.value
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,222 @@
1
+ # Credit due to Nick Sutterer, whose +Uber::InheritableAttr+ provides much
2
+ # of the code that appears with modification here.
3
+ # @see https://github.com/apotonick/uber
4
+ # @see https://raw.githubusercontent.com/apotonick/uber/master/LICENSE
5
+
6
+ # +Uber+'s license reproduced here:
7
+ # # Copyright (c) 2012 Nick Sutterer
8
+ #
9
+ # MIT License
10
+ #
11
+ # Permission is hereby granted, free of charge, to any person obtaining
12
+ # a copy of this software and associated documentation files (the
13
+ # "Software"), to deal in the Software without restriction, including
14
+ # without limitation the rights to use, copy, modify, merge, publish,
15
+ # distribute, sublicense, and/or sell copies of the Software, and to
16
+ # permit persons to whom the Software is furnished to do so, subject to
17
+ # the following conditions:
18
+ #
19
+ # The above copyright notice and this permission notice shall be
20
+ # included in all copies or substantial portions of the Software.
21
+ #
22
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
23
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
24
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
25
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
26
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
27
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
28
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
29
+
30
+ require 'fpm/cookery/path'
31
+
32
+ module FPM
33
+ module Cookery
34
+ # Provides inheritance of class-level attributes. Attributes are cloned
35
+ # from the superclass, except for non-clonable attributes, which are
36
+ # assigned directly.
37
+ #
38
+ # This module will automatically define class methods for keeping track of
39
+ # inheritable attributes, as follows:
40
+ # +attr_rw+ => +klass.scalar_attrs+
41
+ # +attr_rw_list+ => +klass.list_attrs+
42
+ # +attr_rw_hash+ => +klass.hash_attrs+
43
+ # +attr_rw_path+ => +klass.path_attrs+
44
+ #
45
+ # @example
46
+ # class Foo
47
+ # extend FPM::Cookery::InheritableAttr
48
+ #
49
+ # attr_rw :name, :rank
50
+ # attr_rw_list :favorite_things
51
+ # attr_rw_hash :meta
52
+ # attr_rw_path :home
53
+ #
54
+ # name("J.J. Jingleheimer-Schmidt")
55
+ # favorite_things("brown paper packages", "raindrops on roses")
56
+ # meta[:data] = "la la la la la la la la"
57
+ # home = "/home/jjschmidt"
58
+ # end
59
+ #
60
+ # Bar = Class.new(Foo)
61
+ # Bar.home #=> #<FPM::Cookery::Path:/home/jjschmidt>
62
+ # Bar.home = "/home/free" #=> #<FPM::Cookery::Path:/home/free>
63
+ # Foo.home #=> #<FPM::Cookery::Path:/home/jjschmidt>
64
+ #
65
+ # Foo.scalar_attrs #=> [:name, :rank]
66
+ # Foo.list_attrs #=> [:favorite_things]
67
+ # Foo.hash_attrs #=> [:meta]
68
+ # Foo.path_attrs #=> [:home]
69
+ module InheritableAttr
70
+ # Adds a list of attributes keyed to the +type+ key of an internal hash
71
+ # tracking class attributes. Also defines the method +"#{type}_attrs"+,
72
+ # which will return the list of attribute names keyed to +type+.
73
+ # @example
74
+ def register_attrs(type, *attrs)
75
+ (attr_registry[type] ||= []).concat(attrs)
76
+
77
+ unless respond_to?(type_reader = :"#{type}_attrs")
78
+ (class << self ; self ; end).send(:define_method, type_reader) do
79
+ attr_registry.fetch(type, []).dup
80
+ end
81
+ end
82
+ end
83
+
84
+ # Create `scalar' (i.e. non-collection) attributes.
85
+ def attr_rw(*attrs)
86
+ attrs.each do |attr|
87
+ class_eval %Q{
88
+ def self.#{attr}(value = nil)
89
+ if value.nil?
90
+ return @#{attr} if instance_variable_defined?(:@#{attr})
91
+ @#{attr} = InheritableAttr.inherit_for(self, :#{attr})
92
+ else
93
+ @#{attr} = value
94
+ end
95
+ end
96
+
97
+ def #{attr}
98
+ self.class.#{attr}
99
+ end
100
+ }
101
+ end
102
+
103
+ register_attrs(:scalar, *attrs)
104
+ end
105
+
106
+ # Create list-style attributes, backed by +Array+s. +nil+ entries will be
107
+ # filtered, non-unique entries will be culled to one instance only, and
108
+ # the list will be flattened.
109
+ def attr_rw_list(*attrs)
110
+ attrs.each do |attr|
111
+ class_eval %Q{
112
+ def self.#{attr}(*list)
113
+ unless instance_variable_defined?(:@#{attr})
114
+ @#{attr} = InheritableAttr.inherit_for(self, :#{attr})
115
+ end
116
+
117
+ @#{attr} ||= []
118
+
119
+ unless list.empty?
120
+ @#{attr} << list
121
+ @#{attr}.flatten!
122
+ @#{attr}.uniq!
123
+ end
124
+
125
+ @#{attr}
126
+ end
127
+
128
+ def #{attr}
129
+ self.class.#{attr}
130
+ end
131
+ }
132
+ end
133
+
134
+ register_attrs(:list, *attrs)
135
+ end
136
+
137
+ # Create +Hash+-style attributes. Supports both hash and argument
138
+ # assignment:
139
+ # attr_method[:attr1] = xxxx
140
+ # attr_method :xxxx=>1, :yyyy=>2
141
+ def attr_rw_hash(*attrs)
142
+ attrs.each do |attr|
143
+ class_eval %Q{
144
+ def self.#{attr}(args = {})
145
+ unless instance_variable_defined?(:@#{attr})
146
+ @#{attr} = InheritableAttr.inherit_for(self, :#{attr})
147
+ end
148
+
149
+ (@#{attr} ||= {}).merge!(args)
150
+ end
151
+
152
+ def #{attr}
153
+ self.class.#{attr}
154
+ end
155
+ }
156
+ end
157
+
158
+ register_attrs(:hash, *attrs)
159
+ end
160
+
161
+ # Create methods for attributes representing paths. Arguments to
162
+ # writer methods will be converted to +FPM::Cookery::Path+ objects.
163
+ def attr_rw_path(*attrs)
164
+ attrs.each do |attr|
165
+ class_eval %Q{
166
+ def self.#{attr}
167
+ return @#{attr} if instance_variable_defined?(:@#{attr})
168
+ @#{attr} = InheritableAttr.inherit_for(self, :#{attr})
169
+ end
170
+
171
+ def self.#{attr}=(value)
172
+ @#{attr} = FPM::Cookery::Path.new(value)
173
+ end
174
+
175
+ def #{attr}=(value)
176
+ self.class.#{attr} = value
177
+ end
178
+
179
+ def #{attr}(path = nil)
180
+ self.class.#{attr}(path)
181
+ end
182
+ }
183
+ end
184
+
185
+ register_attrs(:path, *attrs)
186
+ end
187
+
188
+ class << self
189
+ def inherit_for(klass, name)
190
+ return unless klass.superclass.respond_to?(name)
191
+ DeepClone.(klass.superclass.send(name))
192
+ end
193
+
194
+ def extended(klass)
195
+ # Inject the +attr_registry+ attribute into any class that extends
196
+ # this module.
197
+ klass.attr_rw_hash(:attr_registry)
198
+ end
199
+ end
200
+
201
+ # Provides deep cloning of data structures. Used in
202
+ # +InheritableAttr.inherit_for+ to, among other things, avoid
203
+ # accidentally propagating to the superclass changes made to
204
+ # substructures of an attribute (such as arrays contained in a hash
205
+ # attribute).
206
+ class DeepClone
207
+ def self.call(obj)
208
+ case obj
209
+ when Hash
210
+ obj.class[obj.map { |k, v| [DeepClone.(k), DeepClone.(v)] }]
211
+ when Array
212
+ obj.map { |v| DeepClone.(v) }
213
+ when Symbol, TrueClass, FalseClass, NilClass, Integer, Float
214
+ obj
215
+ else
216
+ obj.respond_to?(:clone) ? obj.clone : obj
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end