hammer_cli_import 0.10.21

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,351 @@
1
+ #
2
+ # Copyright (c) 2014 Red Hat Inc.
3
+ #
4
+ # This file is part of hammer-cli-import.
5
+ #
6
+ # hammer-cli-import is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # hammer-cli-import is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with hammer-cli-import. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+
20
+ # Modules to help with imports. To be used as Extend/Import on classes that inherit
21
+ # from module HammerCLIImport::BaseCommand.
22
+
23
+ require 'logger'
24
+
25
+ module ImportTools
26
+ module Repository
27
+ module Extend
28
+ def add_repo_options
29
+ option ['--synchronize'], :flag, 'Synchronize imported repositories', :default => false
30
+ option ['--wait'], :flag, 'Wait for repository synchronization to finish', :default => false
31
+
32
+ add_async_tasks_reactor_options
33
+
34
+ validate_options do
35
+ option(:option_synchronize).required if option(:option_wait).exist?
36
+ end
37
+ end
38
+ end
39
+
40
+ module Include
41
+ def repo_synced?(repo)
42
+ raise ArgumentError, 'nil is not a valid repository' if repo.nil?
43
+
44
+ info = lookup_entity(:repositories, repo['id'], true)
45
+ last_sync_result = info['last_sync']['result'] if info['last_sync']
46
+ return false unless (info['sync_state'] == 'finished' || last_sync_result == 'success')
47
+
48
+ ## (Temporary) workaround for 1131954
49
+ ## updated_at is updated after sync for some reason...
50
+ # begin
51
+ # Time.parse(info['last_sync']) > Time.parse(info['updated_at'])
52
+ # rescue
53
+ # false
54
+ # end
55
+ true
56
+ end
57
+
58
+ def sync_repo(repo)
59
+ return unless option_synchronize?
60
+ task = api_call(:repositories, :sync, {:id => repo['id']})
61
+ debug "Sync of repo #{repo['id']} started!"
62
+ return unless option_wait?
63
+ wait_for_task task['id']
64
+ end
65
+
66
+ def sync_repo2(repo)
67
+ task = api_call(:repositories, :sync, {:id => repo['id']})
68
+ debug "Sync of repo #{repo['id']} started!"
69
+ task['id']
70
+ rescue
71
+ uuid = workaround_1116063 repo['id']
72
+ info 'Sync already running!'
73
+ uuid
74
+ end
75
+
76
+ def with_synced_repo(repo, &block)
77
+ # So we can not give empty block
78
+ if block_given?
79
+ action = block
80
+ else
81
+ action = proc {}
82
+ end
83
+
84
+ # Already synchronized? Do the Thing
85
+ # ElsIf asked to sync, sync-and-then-wait to Do the Thing
86
+ # Otherwise, shrug and Skip The Thing
87
+ if repo_synced?(repo)
88
+ action.call
89
+ elsif option_synchronize?
90
+ uuid = sync_repo2 repo
91
+ postpone_till([uuid], &action) if option_wait?
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ # When BZ 1116063 get fixed, this might
98
+ # be simplified using either
99
+ # > api_call(:repositories, :show, {'id' => 1})
100
+ # or maybe with
101
+ # > api_call(:sync, :index, {:repository_id => 1})
102
+ def workaround_1116063(repo_id)
103
+ res = api_call :foreman_tasks, :bulk_search, \
104
+ :searches => [{:type => :resource, :resource_type => 'Katello::Repository', :resource_id => repo_id}]
105
+
106
+ res.first['results'] \
107
+ .select { |x| x['result'] != 'error' } \
108
+ .max_by { |x| Time.parse(x['started_at']) }['id']
109
+ end
110
+ end
111
+ end
112
+
113
+ module LifecycleEnvironment
114
+ module Include
115
+ def get_env(org_id, name = 'Library')
116
+ @lc_environments ||= {}
117
+ @lc_environments[org_id] ||= {}
118
+ unless @lc_environments[org_id][name]
119
+ res = api_call :lifecycle_environments, :index, {:organization_id => org_id, :name => name}
120
+ @lc_environments[org_id][name] = res['results'].find { |x| x['name'] == name }
121
+ end
122
+ @lc_environments[org_id][name]
123
+ end
124
+ end
125
+ end
126
+
127
+ module Task
128
+ module Include
129
+ # [uuid] -> {uuid => {:finished => bool, :progress => Float}}
130
+ def annotate_tasks(uuids)
131
+ ret = {}
132
+ get_tasks_statuses(uuids).each do |uuid, stat|
133
+ ret[uuid] = { :finished => stat['state'] == 'stopped',
134
+ :progress => stat['progress']}
135
+ end
136
+ ret
137
+ end
138
+
139
+ private
140
+
141
+ # [uuid] -> {uuid: task_status}
142
+ def get_tasks_statuses(uuids)
143
+ searches = uuids.collect { |uuid| {:type => :task, :task_id => uuid} }
144
+ ret = api_call :foreman_tasks, :bulk_search, {:searches => searches}
145
+ statuses = {}
146
+ ret.each do |status_result|
147
+ status_result['results'].each do |task_info|
148
+ statuses[task_info['id']] = task_info
149
+ end
150
+ end
151
+ statuses
152
+ end
153
+ end
154
+ end
155
+
156
+ module ContentView
157
+ module Include
158
+ def publish_content_view(id, entity_type = :content_views)
159
+ mapped_api_call entity_type, :publish, {:id => id}
160
+ rescue => e
161
+ warn "Publishing of #{to_singular(entity_type)} [#{id}] failed with #{e.class}: #{e.message}"
162
+ end
163
+
164
+ def create_composite_content_view(entity_type, org_id, cv_label, cv_description, cvs)
165
+ return nil if cvs.empty?
166
+ if cvs.size == 1
167
+ return cvs.to_a[0]
168
+ else
169
+ # create composite content view
170
+ cv_versions = []
171
+ cvs.each do |cv_id|
172
+ cvvs = list_server_entities(:content_view_versions, {:content_view_id => cv_id})
173
+ cv_versions << cvvs.collect { |cv| cv['id'] }.max
174
+ end
175
+ cv = lookup_entity_in_cache(entity_type, 'label' => cv_label)
176
+ if cv
177
+ info " Content view #{cv_label} already created, reusing."
178
+ else
179
+ # create composite content view
180
+ # for activation key purposes
181
+ cv = create_entity(
182
+ entity_type,
183
+ {
184
+ :organization_id => org_id,
185
+ :name => cv_label,
186
+ :label => cv_label,
187
+ :composite => true,
188
+ :description => cv_description,
189
+ :component_ids => cv_versions
190
+ },
191
+ cv_label)
192
+ # publish the content view
193
+ info " Publishing content view: #{cv['id']}"
194
+ publish_content_view(cv['id'], entity_type)
195
+ end
196
+ return cv['id']
197
+ end
198
+ end
199
+
200
+ # use entity_type as parameter to be able to re-use the method for
201
+ # :content_views, :ak_content_views, :redhat_content_views, ...
202
+ def delete_content_view(cv_id, entity_type = :content_views)
203
+ raise "delete_content_view with #{entity_type}" unless map_target_entity[entity_type] == :content_views
204
+
205
+ content_view = get_cache(entity_type)[cv_id]
206
+ # first delete the content view from associated environments
207
+ cves = content_view['environments'].collect { |e| e['id'] }
208
+ cves.each do |cve|
209
+ begin
210
+ task = mapped_api_call(
211
+ entity_type,
212
+ :remove_from_environment,
213
+ {
214
+ :id => content_view['id'],
215
+ :environment_id => cve
216
+ })
217
+ rescue => e
218
+ warn "Failed to remove content view [#{cv_id}] from environment #{cve} with #{e.class}: #{e.message}"
219
+ end
220
+ wait_for_task(task['id'], 1, 0)
221
+ end
222
+
223
+ if content_view['versions'] && !content_view['versions'].empty?
224
+ cv_version_ids = content_view['versions'].collect { |v| v['id'] }
225
+
226
+ begin
227
+ task = mapped_api_call(
228
+ entity_type,
229
+ :remove,
230
+ {
231
+ :id => content_view['id'],
232
+ :content_view_version_ids => cv_version_ids
233
+ })
234
+
235
+ wait_for_task(task['id'], 1, 0)
236
+ rescue => e
237
+ warn "Failed to remove versions of content view [#{cv_id}] with #{e.class}: #{e.message}"
238
+ end
239
+ else
240
+ debug "No versions found for #{to_singular(entity_type)} #{cv_id}"
241
+ end
242
+
243
+ delete_entity_by_import_id(entity_type, content_view['id'])
244
+ end
245
+ end
246
+ end
247
+
248
+ module ImportLogging
249
+ module Extend
250
+ def add_logging_options
251
+ # Logging options
252
+ # quiet = go to logfile only
253
+ # verbose = all output goes to STDOUT as well as log
254
+ # debug = enable debug-output
255
+ # default = no debug, only PROGRESS-and-above to STDOUT
256
+ option ['--quiet'], :flag, 'Be silent - no output to STDOUT', :default => false
257
+ option ['--debug'], :flag, 'Turn on debugging-information', :default => false
258
+ option ['--verbose'],
259
+ :flag,
260
+ 'Be noisy - everything goes to STDOUT and to a logfile',
261
+ :default => false
262
+ option ['--logfile'],
263
+ 'LOGFILE',
264
+ 'Where output is logged to',
265
+ :default => File.expand_path('~/import.log')
266
+ end
267
+ end
268
+
269
+ module Include
270
+ def setup_logging
271
+ @curr_lvl = Logger::INFO
272
+ @curr_lvl = Logger::DEBUG if option_debug?
273
+
274
+ @logger = Logger.new(File.new(option_logfile, 'a'))
275
+ @logger.level = @curr_lvl
276
+ end
277
+
278
+ def debug(s)
279
+ log(Logger::DEBUG, s)
280
+ end
281
+
282
+ def info(s)
283
+ log(Logger::INFO, s)
284
+ end
285
+
286
+ def progress(s)
287
+ log(Logger::INFO, s, true)
288
+ end
289
+
290
+ def warn(s)
291
+ log(Logger::WARN, s)
292
+ end
293
+
294
+ def error(s)
295
+ log(Logger::ERROR, s)
296
+ end
297
+
298
+ def fatal(s)
299
+ log(Logger::FATAL, s)
300
+ end
301
+
302
+ def logtrace(e)
303
+ @logger.log(Logger::ERROR, (e.backtrace.join "\n"))
304
+ end
305
+
306
+ def log(lvl, s, always = false)
307
+ @logger.log(lvl, s)
308
+ return if option_quiet?
309
+
310
+ if always
311
+ puts s
312
+ elsif option_verbose?
313
+ puts s if lvl >= @curr_lvl
314
+ else
315
+ puts s if lvl > @curr_lvl
316
+ end
317
+ end
318
+ end
319
+ end
320
+
321
+ module Exceptional
322
+ module Include
323
+ def handle_missing_and_supress(what, &block)
324
+ block.call
325
+ rescue HammerCLIImport::MissingObjectError => moe
326
+ error moe.message
327
+ rescue => e
328
+ error "Caught #{e.class}:#{e.message} while #{what}"
329
+ logtrace e
330
+ end
331
+
332
+ # this method catches everything sent to stdout and stderr
333
+ # and disallows any summary changes
334
+ #
335
+ # this is a bad hack, but we need it, as sat6 cannot tell,
336
+ # whether there're still content hosts associated with a content view
337
+ # so we try to delete system content views silently
338
+ def silently(&block)
339
+ summary_backup = @summary.clone
340
+ $stdout = StringIO.new
341
+ $stderr = StringIO.new
342
+ block.call
343
+ ensure
344
+ $stdout = STDOUT
345
+ $stderr = STDERR
346
+ @summary = summary_backup
347
+ end
348
+ end
349
+ end
350
+ end
351
+ # vim: autoindent tabstop=2 shiftwidth=2 expandtab softtabstop=2 filetype=ruby
@@ -0,0 +1,110 @@
1
+ #
2
+ # Copyright (c) 2014 Red Hat Inc.
3
+ #
4
+ # This file is part of hammer-cli-import.
5
+ #
6
+ # hammer-cli-import is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # hammer-cli-import is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with hammer-cli-import. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+
20
+ require 'hammer_cli'
21
+
22
+ module HammerCLIImport
23
+ class ImportCommand
24
+ class OrganizationImportCommand < BaseCommand
25
+ command_name 'organization'
26
+ reportname = 'users'
27
+ desc "Import Organizations (from spacewalk-report #{reportname})."
28
+
29
+ option ['--into-org-id'], 'ORG_ID', 'Import all organizations into one specified by id' do |x|
30
+ Integer(x)
31
+ end
32
+
33
+ # Where do we expect to find manifest-files?
34
+ # NOTE: we won't upload manifests if we're doing into-org-id - the expectation is that
35
+ # you have already set up your org
36
+ option ['--upload-manifests-from'],
37
+ 'MANIFEST_DIR',
38
+ 'Upload manifests found at MANIFEST_DIR. Assumes manifest for "ORG NAME" will be of the form ORG_NAME.zip'
39
+
40
+ csv_columns 'organization_id', 'organization'
41
+
42
+ persistent_maps :organizations
43
+
44
+ def mk_org_hash(data)
45
+ {
46
+ :id => data['organization_id'].to_i,
47
+ :name => data['organization'],
48
+ :description => "Imported '#{data['organization']}' organization from Red Hat Satellite 5"
49
+ }
50
+ end
51
+
52
+ # :subscriptions :upload {:org_id => id, :content => File.new(filename, 'rb')}
53
+ def upload_manifest_for(label, id)
54
+ # Remember labels we've already processed in this run
55
+ @manifests ||= []
56
+ return if @manifests.include? label
57
+
58
+ @manifests << label
59
+ filename = option_upload_manifests_from + '/' + label + '.zip'
60
+ unless File.exist? filename
61
+ error "No manifest #{filename} available."
62
+ return
63
+ end
64
+
65
+ info "Uploading manifest #{filename} to org-id #{id}"
66
+ manifest_file = File.new(filename, 'rb')
67
+ request_headers = {:content_type => 'multipart/form-data', :multipart => true}
68
+
69
+ rc = api_call :subscriptions, :upload, {:organization_id => id, :content => manifest_file}, request_headers
70
+ wait_for_task(rc['id'])
71
+ report_summary :uploaded, :manifests
72
+ end
73
+
74
+ def import_single_row(data)
75
+ if option_into_org_id
76
+ unless lookup_entity_in_cache(:organizations, {'id' => option_into_org_id})
77
+ warn "Organization [#{option_into_org_id}] not found. Skipping."
78
+ return
79
+ end
80
+ map_entity(:organizations, data['organization_id'].to_i, option_into_org_id)
81
+ return
82
+ end
83
+ org = mk_org_hash data
84
+ new_org = create_entity(:organizations, org, data['organization_id'].to_i)
85
+ upload_manifest_for(new_org['label'], new_org['id']) unless option_upload_manifests_from.nil?
86
+ end
87
+
88
+ def delete_single_row(data)
89
+ org_id = data['organization_id'].to_i
90
+ unless @pm[:organizations][org_id]
91
+ warn "#{to_singular(:organizations).capitalize} with id #{org_id} wasn't imported. Skipping deletion."
92
+ return
93
+ end
94
+ target_org_id = get_translated_id(:organizations, org_id)
95
+ if last_in_cache?(:organizations, target_org_id)
96
+ warn "Won't delete last organization [#{target_org_id}]. Unmapping only."
97
+ unmap_entity(:organizations, target_org_id)
98
+ return
99
+ end
100
+ if target_org_id == 1
101
+ warn "Won't delete organization with id [#{target_org_id}]. Unmapping only."
102
+ unmap_entity(:organizations, target_org_id)
103
+ return
104
+ end
105
+ delete_entity(:organizations, org_id)
106
+ end
107
+ end
108
+ end
109
+ end
110
+ # vim: autoindent tabstop=2 shiftwidth=2 expandtab softtabstop=2 filetype=ruby
@@ -0,0 +1,225 @@
1
+ #
2
+ # Copyright (c) 2014 Red Hat Inc.
3
+ #
4
+ # This file is part of hammer-cli-import.
5
+ #
6
+ # hammer-cli-import is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # hammer-cli-import is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with hammer-cli-import. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+
20
+ require 'csv'
21
+ require 'set'
22
+
23
+ module PersistentMap
24
+ class PersistentMapError < RuntimeError
25
+ end
26
+
27
+ class << Fixnum
28
+ def from_s(x)
29
+ Integer(x) rescue 0
30
+ end
31
+ end
32
+
33
+ class << String
34
+ def from_s(x)
35
+ x
36
+ end
37
+ end
38
+
39
+ class << self
40
+ def definitions
41
+ return @definitions if @definitions
42
+ @definitions = {}
43
+
44
+ [:content_views, :host_collections, :organizations, :repositories, :users].each do |symbol|
45
+ @definitions[symbol] = ['sat5' => Fixnum], ['sat6' => Fixnum], symbol
46
+ end
47
+
48
+ @definitions[:activation_keys] = ['org_id' => String], ['sat6' => Fixnum], :activation_keys
49
+ @definitions[:ak_content_views] = ['ak_id' => String], ['sat6' => Fixnum], :content_views
50
+ @definitions[:system_content_views] = ['ch_seq' => String], ['sat6' => Fixnum], :content_views
51
+ @definitions[:local_repositories] = [{'org_id' => Fixnum}, {'channel_id' => Fixnum}], ['sat6' => Fixnum], :repositories
52
+ @definitions[:products] = [{'org_id' => Fixnum}, {'label' => String}], ['sat6' => Fixnum], :products
53
+ @definitions[:puppet_repositories] = [{'org_id' => Fixnum}, {'channel_id' => Fixnum}],
54
+ ['sat6' => Fixnum], :repositories
55
+ @definitions[:redhat_content_views] = [{'org_id' => Fixnum}, {'channel_id' => Fixnum}], ['sat6' => Fixnum],
56
+ :content_views
57
+ @definitions[:redhat_repositories] = [{'org_id' => Fixnum}, {'channel_id' => Fixnum}], ['sat6' => Fixnum],
58
+ :repositories
59
+ @definitions[:systems] = ['sat5' => Fixnum], ['sat6' => String], :systems
60
+ @definitions[:template_snippets] = ['id' => Fixnum], ['sat6' => Fixnum], :config_templates
61
+
62
+ @definitions.freeze
63
+ end
64
+ end
65
+
66
+ module Extend
67
+ attr_reader :maps, :map_description, :map_target_entity
68
+
69
+ def persistent_map(symbol)
70
+ defs = PersistentMap.definitions
71
+
72
+ raise PersistentMapError, "Unknown persistent map: #{symbol}" unless defs.key? symbol
73
+
74
+ # Names of persistent maps
75
+ @maps ||= []
76
+ @maps.push symbol
77
+
78
+ key_spec, val_spec, target_entity = defs[symbol]
79
+
80
+ # Which entities they are mapped to?
81
+ # Usually they are mapped to the same entities on Sat6 (speaking of api)
82
+ # But sometimes you need to create same type of Sat6 entities based on
83
+ # different Sat5 entities, and then it is time for this extra option.
84
+ @map_target_entity ||= {}
85
+ @map_target_entity[symbol] = target_entity
86
+
87
+ # How keys and values looks like (so they can be nicely stored)
88
+ @map_description ||= {}
89
+ @map_description[symbol] = [key_spec, val_spec]
90
+ end
91
+
92
+ def persistent_maps(*list)
93
+ raise PersistentMapError, 'Persistent maps should be declared only once' if @maps
94
+ list.each do |map_sym|
95
+ persistent_map map_sym
96
+ end
97
+ end
98
+ end
99
+
100
+ module Include
101
+ def maps
102
+ self.class.maps
103
+ end
104
+
105
+ def map_target_entity
106
+ self.class.map_target_entity
107
+ end
108
+
109
+ def load_persistent_maps
110
+ @pm = {}
111
+ maps.each do |map_sym|
112
+ hash = {}
113
+ Dir[File.join data_dir, "#{map_sym}-*.csv"].sort.each do |filename|
114
+ reader = CSV.open(filename, 'r')
115
+ header = reader.shift
116
+ raise PersistentMapError, "Importing :#{map_sym} from file #{filename}" unless header == (pm_csv_headers map_sym)
117
+ reader.each do |row|
118
+ key, value = pm_decode_row map_sym, row
119
+ delkey = row[-1] == '-'
120
+ if delkey
121
+ hash.delete key
122
+ else
123
+ hash[key] = value
124
+ end
125
+ end
126
+ end
127
+ @pm[map_sym] = add_checks(DeltaHash[hash], self.class.map_description[map_sym], map_sym)
128
+ end
129
+ end
130
+
131
+ def save_persistent_maps
132
+ maps.each do |map_sym|
133
+ next unless @pm[map_sym].changed?
134
+ CSV.open((File.join data_dir, "#{map_sym}-#{Time.now.utc.iso8601}.csv"), 'wb') do |csv|
135
+ csv << (pm_csv_headers map_sym)
136
+ @pm[map_sym].new.each do |key, value|
137
+ key = [key] unless key.is_a? Array
138
+ value = [value] unless value.is_a? Array
139
+ csv << key + value + [nil]
140
+ end
141
+ delval = [nil] * (val_arity map_sym)
142
+ @pm[map_sym].del.each do |key|
143
+ key = [key] unless key.is_a? Array
144
+ csv << key + delval + ['-']
145
+ end
146
+ end
147
+ end
148
+ end
149
+
150
+ # Consider entities deleted if they are not present in cache
151
+ def prune_persistent_maps(cache)
152
+ maps.each do |map_sym|
153
+ entity_ids = cache[map_target_entity[map_sym]].keys
154
+ pm_hash = @pm[map_sym].to_hash
155
+ extra = pm_hash.values.to_set - entity_ids.to_set
156
+
157
+ next if extra.empty?
158
+
159
+ debug "Removing #{map_sym} from persistent map: #{extra.to_a.join(' ')}"
160
+ pm_hash.each do |key, value|
161
+ @pm[map_sym].delete key if extra.include? value
162
+ end
163
+ end
164
+ end
165
+
166
+ private
167
+
168
+ # Protective black magic.
169
+ # Checks whether given values and keys matches description
170
+ # at the moment of insertion...
171
+ def add_checks(hash, kv_desc, map_sym)
172
+ hash.instance_eval do
173
+ ks, vs = kv_desc
174
+ @key_desc = ks.collect(&:values) .flatten
175
+ @val_desc = vs.collect(&:values) .flatten
176
+ @map_sym = map_sym
177
+ end
178
+ class << hash
179
+ def []=(ks, vs)
180
+ key = ks
181
+ val = vs
182
+ key = [key] unless key.is_a? Array
183
+ val = [val] unless val.is_a? Array
184
+ raise "Bad key for persistent map #{@map_sym}: (#{key.inspect} - #{@key_desc.inspect})" \
185
+ unless key.size == @key_desc.size && key.zip(@key_desc).all? { |k, d| k.is_a? d }
186
+ raise "Bad value for persistent map #{@map_sym}: (#{val.inspect} - #{@val_desc.inspect}" \
187
+ unless val.size == @val_desc.size && val.zip(@val_desc).all? { |v, d| v.is_a? d }
188
+ super
189
+ end
190
+ end
191
+ hash
192
+ end
193
+
194
+ def pm_decode_row(map_sym, row)
195
+ key_spec, val_spec = self.class.map_description[map_sym]
196
+ key = []
197
+ value = []
198
+
199
+ key_spec.each do |spec|
200
+ x = row.shift
201
+ key.push(spec.values.first.from_s x)
202
+ end
203
+
204
+ val_spec.each do |spec|
205
+ x = row.shift
206
+ value.push(spec.values.first.from_s x)
207
+ end
208
+
209
+ key = key[0] if key.size == 1
210
+ value = value[0] if value.size == 1
211
+ [key, value]
212
+ end
213
+
214
+ def pm_csv_headers(symbol)
215
+ key_spec, val_spec = self.class.map_description[symbol]
216
+ (key_spec + val_spec).collect { |x| x.keys[0] } + ['delete']
217
+ end
218
+
219
+ def val_arity(symbol)
220
+ _key_spec, val_spec = self.class.map_description[symbol]
221
+ val_spec.size
222
+ end
223
+ end
224
+ end
225
+ # vim: autoindent tabstop=2 shiftwidth=2 expandtab softtabstop=2 filetype=ruby