parallel 0.5.21 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/Gemfile.lock +1 -1
- data/lib/parallel.rb +253 -232
- data/lib/parallel/version.rb +1 -1
- data/spec/cases/map_with_killed_worker_before_read.rb +9 -0
- data/spec/cases/map_with_killed_worker_before_write.rb +18 -0
- data/spec/parallel_spec.rb +8 -0
- metadata +8 -6
data/Gemfile.lock
CHANGED
data/lib/parallel.rb
CHANGED
@@ -3,308 +3,329 @@ require 'rbconfig'
|
|
3
3
|
require 'parallel/version'
|
4
4
|
|
5
5
|
module Parallel
|
6
|
-
|
7
|
-
count, options = extract_count_from_options(options)
|
8
|
-
|
9
|
-
out = []
|
10
|
-
threads = []
|
11
|
-
|
12
|
-
count.times do |i|
|
13
|
-
threads[i] = Thread.new do
|
14
|
-
out[i] = yield(i)
|
15
|
-
end
|
16
|
-
end
|
17
|
-
|
18
|
-
wait_for_threads(threads)
|
19
|
-
|
20
|
-
out
|
6
|
+
class DeadWorker < EOFError
|
21
7
|
end
|
22
8
|
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
9
|
+
class ExceptionWrapper
|
10
|
+
attr_reader :exception
|
11
|
+
def initialize(exception)
|
12
|
+
dumpable = Marshal.dump(exception) rescue nil
|
13
|
+
unless dumpable
|
14
|
+
exception = RuntimeError.new("Undumpable Exception -- #{exception.inspect}")
|
15
|
+
end
|
28
16
|
|
29
|
-
|
30
|
-
|
31
|
-
array
|
17
|
+
@exception = exception
|
18
|
+
end
|
32
19
|
end
|
33
20
|
|
34
|
-
|
35
|
-
|
36
|
-
|
21
|
+
class Worker
|
22
|
+
attr_reader :pid, :read, :write
|
23
|
+
def initialize(read, write, pid)
|
24
|
+
@read, @write, @pid = read, write, pid
|
25
|
+
end
|
37
26
|
|
38
|
-
|
39
|
-
|
27
|
+
def close_pipes
|
28
|
+
read.close
|
29
|
+
write.close
|
30
|
+
end
|
40
31
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
method = :in_processes
|
46
|
-
size = options[method] || processor_count
|
32
|
+
def wait
|
33
|
+
Process.wait(pid)
|
34
|
+
rescue Interrupt
|
35
|
+
# process died
|
47
36
|
end
|
48
|
-
size = [array.size, size].min
|
49
37
|
|
50
|
-
|
38
|
+
def work(index)
|
39
|
+
begin
|
40
|
+
Marshal.dump(index, write)
|
41
|
+
rescue Errno::EPIPE
|
42
|
+
raise DeadWorker
|
43
|
+
end
|
51
44
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
45
|
+
begin
|
46
|
+
Marshal.load(read)
|
47
|
+
rescue EOFError
|
48
|
+
raise Parallel::DeadWorker
|
49
|
+
end
|
56
50
|
end
|
57
51
|
end
|
58
52
|
|
59
|
-
|
60
|
-
|
61
|
-
|
53
|
+
class << self
|
54
|
+
def in_threads(options={:count => 2})
|
55
|
+
count, options = extract_count_from_options(options)
|
62
56
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
wmi = WIN32OLE.connect("winmgmts://")
|
76
|
-
cpu = wmi.ExecQuery("select NumberOfLogicalProcessors from Win32_Processor")
|
77
|
-
cpu.to_enum.first.NumberOfLogicalProcessors
|
78
|
-
when /solaris2/
|
79
|
-
`psrinfo -p`.to_i # this is physical cpus afaik
|
80
|
-
else
|
81
|
-
$stderr.puts "Unknown architecture ( #{RbConfig::CONFIG["host_os"]} ) assuming one processor."
|
82
|
-
1
|
57
|
+
out = []
|
58
|
+
threads = []
|
59
|
+
|
60
|
+
count.times do |i|
|
61
|
+
threads[i] = Thread.new do
|
62
|
+
out[i] = yield(i)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
wait_for_threads(threads)
|
67
|
+
|
68
|
+
out
|
83
69
|
end
|
84
|
-
end
|
85
70
|
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
when /linux/
|
91
|
-
`grep cores /proc/cpuinfo`[/\d+/].to_i
|
92
|
-
when /mswin|mingw/
|
93
|
-
require 'win32ole'
|
94
|
-
wmi = WIN32OLE.connect("winmgmts://")
|
95
|
-
cpu = wmi.ExecQuery("select NumberOfProcessors from Win32_Processor")
|
96
|
-
cpu.to_enum.first.NumberOfLogicalProcessors
|
97
|
-
else
|
98
|
-
processor_count
|
71
|
+
def in_processes(options = {}, &block)
|
72
|
+
count, options = extract_count_from_options(options)
|
73
|
+
count ||= processor_count
|
74
|
+
map(0...count, options.merge(:in_processes => count), &block)
|
99
75
|
end
|
100
|
-
end
|
101
76
|
|
102
|
-
|
77
|
+
def each(array, options={}, &block)
|
78
|
+
map(array, options.merge(:preserve_results => false), &block)
|
79
|
+
array
|
80
|
+
end
|
103
81
|
|
104
|
-
|
105
|
-
|
106
|
-
array.each_with_index do |e,i|
|
107
|
-
results << (options[:with_index] ? yield(e,i) : yield(e))
|
82
|
+
def each_with_index(array, options={}, &block)
|
83
|
+
each(array, options.merge(:with_index => true), &block)
|
108
84
|
end
|
109
|
-
results
|
110
|
-
end
|
111
85
|
|
112
|
-
|
113
|
-
|
114
|
-
end
|
86
|
+
def map(array, options = {}, &block)
|
87
|
+
array = array.to_a # turn Range and other Enumerable-s into an Array
|
115
88
|
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
89
|
+
if options[:in_threads]
|
90
|
+
method = :in_threads
|
91
|
+
size = options[method]
|
92
|
+
else
|
93
|
+
method = :in_processes
|
94
|
+
size = options[method] || processor_count
|
95
|
+
end
|
96
|
+
size = [array.size, size].min
|
122
97
|
|
123
|
-
|
124
|
-
# as long as there are more items, work on one of them
|
125
|
-
loop do
|
126
|
-
break if exception
|
98
|
+
return work_direct(array, options, &block) if size == 0
|
127
99
|
|
128
|
-
|
129
|
-
|
100
|
+
if method == :in_threads
|
101
|
+
work_in_threads(array, options.merge(:count => size), &block)
|
102
|
+
else
|
103
|
+
work_in_processes(array, options.merge(:count => size), &block)
|
104
|
+
end
|
105
|
+
end
|
130
106
|
|
131
|
-
|
132
|
-
|
107
|
+
def map_with_index(array, options={}, &block)
|
108
|
+
map(array, options.merge(:with_index => true), &block)
|
109
|
+
end
|
133
110
|
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
111
|
+
def processor_count
|
112
|
+
@processor_count ||= case RbConfig::CONFIG['host_os']
|
113
|
+
when /darwin9/
|
114
|
+
`hwprefs cpu_count`.to_i
|
115
|
+
when /darwin/
|
116
|
+
(hwprefs_available? ? `hwprefs thread_count` : `sysctl -n hw.ncpu`).to_i
|
117
|
+
when /linux|cygwin/
|
118
|
+
`grep -c processor /proc/cpuinfo`.to_i
|
119
|
+
when /(open|free)bsd/
|
120
|
+
`sysctl -n hw.ncpu`.to_i
|
121
|
+
when /mswin|mingw/
|
122
|
+
require 'win32ole'
|
123
|
+
wmi = WIN32OLE.connect("winmgmts://")
|
124
|
+
cpu = wmi.ExecQuery("select NumberOfLogicalProcessors from Win32_Processor")
|
125
|
+
cpu.to_enum.first.NumberOfLogicalProcessors
|
126
|
+
when /solaris2/
|
127
|
+
`psrinfo -p`.to_i # this is physical cpus afaik
|
128
|
+
else
|
129
|
+
$stderr.puts "Unknown architecture ( #{RbConfig::CONFIG["host_os"]} ) assuming one processor."
|
130
|
+
1
|
142
131
|
end
|
143
132
|
end
|
144
133
|
|
145
|
-
|
134
|
+
def physical_processor_count
|
135
|
+
@physical_processor_count ||= case RbConfig::CONFIG['host_os']
|
136
|
+
when /darwin1/, /freebsd/
|
137
|
+
`sysctl -n hw.physicalcpu`.to_i
|
138
|
+
when /linux/
|
139
|
+
`grep cores /proc/cpuinfo`[/\d+/].to_i
|
140
|
+
when /mswin|mingw/
|
141
|
+
require 'win32ole'
|
142
|
+
wmi = WIN32OLE.connect("winmgmts://")
|
143
|
+
cpu = wmi.ExecQuery("select NumberOfProcessors from Win32_Processor")
|
144
|
+
cpu.to_enum.first.NumberOfLogicalProcessors
|
145
|
+
else
|
146
|
+
processor_count
|
147
|
+
end
|
148
|
+
end
|
146
149
|
|
147
|
-
|
148
|
-
end
|
150
|
+
private
|
149
151
|
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
152
|
+
def work_direct(array, options)
|
153
|
+
results = []
|
154
|
+
array.each_with_index do |e,i|
|
155
|
+
results << (options[:with_index] ? yield(e,i) : yield(e))
|
156
|
+
end
|
157
|
+
results
|
158
|
+
end
|
159
|
+
|
160
|
+
def hwprefs_available?
|
161
|
+
`which hwprefs` != ''
|
162
|
+
end
|
157
163
|
|
158
|
-
|
159
|
-
|
164
|
+
def work_in_threads(items, options, &block)
|
165
|
+
results = []
|
166
|
+
current = -1
|
167
|
+
exception = nil
|
160
168
|
|
161
|
-
|
169
|
+
in_threads(options[:count]) do
|
170
|
+
# as long as there are more items, work on one of them
|
162
171
|
loop do
|
163
172
|
break if exception
|
164
|
-
index = Thread.exclusive{ current_index += 1 }
|
165
|
-
break if index >= items.size
|
166
173
|
|
167
|
-
|
168
|
-
|
169
|
-
Marshal.dump(index, worker[:write])
|
170
|
-
on_start.call(item, index) if on_start
|
171
|
-
|
172
|
-
output = Marshal.load(worker[:read])
|
173
|
-
on_finish.call(item, index) if on_finish
|
174
|
+
index = Thread.exclusive{ current+=1 }
|
175
|
+
break if index >= items.size
|
174
176
|
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
177
|
+
with_instrumentation items[index], index, options do
|
178
|
+
begin
|
179
|
+
results[index] = call_with_index(items, index, options, &block)
|
180
|
+
rescue Exception => e
|
181
|
+
exception = e
|
182
|
+
break
|
183
|
+
end
|
179
184
|
end
|
180
185
|
end
|
181
|
-
ensure
|
182
|
-
close_pipes(worker)
|
183
|
-
wait_for_process worker[:pid] # if it goes zombie, rather wait here to be able to debug
|
184
186
|
end
|
185
|
-
end
|
186
187
|
|
187
|
-
|
188
|
+
raise exception if exception
|
188
189
|
|
189
|
-
|
190
|
-
end
|
191
|
-
|
192
|
-
def self.create_workers(items, options, &block)
|
193
|
-
workers = []
|
194
|
-
Array.new(options[:count]).each do
|
195
|
-
workers << worker(items, options.merge(:started_workers => workers), &block)
|
190
|
+
results
|
196
191
|
end
|
197
192
|
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
193
|
+
def work_in_processes(items, options, &blk)
|
194
|
+
workers = create_workers(items, options, &blk)
|
195
|
+
current_index = -1
|
196
|
+
results = []
|
197
|
+
exception = nil
|
202
198
|
|
203
|
-
|
204
|
-
|
205
|
-
GC.copy_on_write_friendly = true if GC.respond_to?(:copy_on_write_friendly=)
|
199
|
+
in_threads(options[:count]) do |i|
|
200
|
+
worker = workers[i]
|
206
201
|
|
207
|
-
|
208
|
-
|
202
|
+
begin
|
203
|
+
loop do
|
204
|
+
break if exception
|
205
|
+
index = Thread.exclusive{ current_index += 1 }
|
206
|
+
break if index >= items.size
|
207
|
+
|
208
|
+
output = with_instrumentation items[index], index, options do
|
209
|
+
worker.work(index)
|
210
|
+
end
|
211
|
+
|
212
|
+
if ExceptionWrapper === output
|
213
|
+
exception = output.exception
|
214
|
+
else
|
215
|
+
results[index] = output
|
216
|
+
end
|
217
|
+
end
|
218
|
+
ensure
|
219
|
+
worker.close_pipes
|
220
|
+
worker.wait # if it goes zombie, rather wait here to be able to debug
|
221
|
+
end
|
222
|
+
end
|
209
223
|
|
210
|
-
|
211
|
-
begin
|
212
|
-
options.delete(:started_workers).each{|w| close_pipes w }
|
224
|
+
raise exception if exception
|
213
225
|
|
214
|
-
|
215
|
-
|
226
|
+
results
|
227
|
+
end
|
216
228
|
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
229
|
+
def create_workers(items, options, &block)
|
230
|
+
workers = []
|
231
|
+
Array.new(options[:count]).each do
|
232
|
+
workers << worker(items, options.merge(:started_workers => workers), &block)
|
221
233
|
end
|
234
|
+
|
235
|
+
kill_on_ctrl_c(workers.map(&:pid))
|
236
|
+
workers
|
222
237
|
end
|
223
238
|
|
224
|
-
|
225
|
-
|
239
|
+
def worker(items, options, &block)
|
240
|
+
# use less memory on REE
|
241
|
+
GC.copy_on_write_friendly = true if GC.respond_to?(:copy_on_write_friendly=)
|
226
242
|
|
227
|
-
|
228
|
-
|
243
|
+
child_read, parent_write = IO.pipe
|
244
|
+
parent_read, child_write = IO.pipe
|
229
245
|
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
end
|
246
|
+
pid = Process.fork do
|
247
|
+
begin
|
248
|
+
options.delete(:started_workers).each(&:close_pipes)
|
234
249
|
|
235
|
-
|
236
|
-
|
237
|
-
index = Marshal.load(read)
|
238
|
-
begin
|
239
|
-
result = call_with_index(items, index, options, &block)
|
240
|
-
result = nil if options[:preserve_results] == false
|
241
|
-
rescue Exception => e
|
242
|
-
result = ExceptionWrapper.new(e)
|
243
|
-
end
|
244
|
-
Marshal.dump(result, write)
|
245
|
-
end
|
246
|
-
end
|
250
|
+
parent_write.close
|
251
|
+
parent_read.close
|
247
252
|
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
# thread died, do not stop other threads
|
253
|
+
process_incoming_jobs(child_read, child_write, items, options, &block)
|
254
|
+
ensure
|
255
|
+
child_read.close
|
256
|
+
child_write.close
|
257
|
+
end
|
254
258
|
end
|
255
|
-
end
|
256
|
-
end
|
257
259
|
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
# process died
|
260
|
+
child_read.close
|
261
|
+
child_write.close
|
262
|
+
|
263
|
+
Worker.new(parent_read, parent_write, pid)
|
263
264
|
end
|
264
|
-
end
|
265
265
|
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
266
|
+
def process_incoming_jobs(read, write, items, options, &block)
|
267
|
+
while !read.eof?
|
268
|
+
index = Marshal.load(read)
|
269
|
+
begin
|
270
|
+
result = call_with_index(items, index, options, &block)
|
271
|
+
result = nil if options[:preserve_results] == false
|
272
|
+
rescue Exception => e
|
273
|
+
result = ExceptionWrapper.new(e)
|
274
|
+
end
|
275
|
+
Marshal.dump(result, write)
|
276
|
+
end
|
273
277
|
end
|
274
|
-
[count, options]
|
275
|
-
end
|
276
278
|
|
277
|
-
|
278
|
-
|
279
|
-
Signal.trap :SIGINT do
|
280
|
-
$stderr.puts 'Parallel execution interrupted, exiting ...'
|
281
|
-
pids.compact.each do |pid|
|
279
|
+
def wait_for_threads(threads)
|
280
|
+
threads.compact.each do |t|
|
282
281
|
begin
|
283
|
-
|
284
|
-
rescue
|
285
|
-
#
|
286
|
-
# so we just ignore them not being there
|
282
|
+
t.join
|
283
|
+
rescue Interrupt
|
284
|
+
# thread died, do not stop other threads
|
287
285
|
end
|
288
286
|
end
|
289
|
-
exit 1 # Quit with 'failed' signal
|
290
287
|
end
|
291
|
-
end
|
292
288
|
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
289
|
+
# options is either a Integer or a Hash with :count
|
290
|
+
def extract_count_from_options(options)
|
291
|
+
if options.is_a?(Hash)
|
292
|
+
count = options[:count]
|
293
|
+
else
|
294
|
+
count = options
|
295
|
+
options = {}
|
296
|
+
end
|
297
|
+
[count, options]
|
298
|
+
end
|
298
299
|
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
300
|
+
# kill all these processes (children) if user presses Ctrl+c
|
301
|
+
def kill_on_ctrl_c(pids)
|
302
|
+
Signal.trap :SIGINT do
|
303
|
+
$stderr.puts 'Parallel execution interrupted, exiting ...'
|
304
|
+
pids.compact.each do |pid|
|
305
|
+
begin
|
306
|
+
Process.kill(:KILL, pid)
|
307
|
+
rescue Errno::ESRCH
|
308
|
+
# some linux systems already automatically killed the children at this point
|
309
|
+
# so we just ignore them not being there
|
310
|
+
end
|
311
|
+
end
|
312
|
+
exit 1 # Quit with 'failed' signal
|
305
313
|
end
|
314
|
+
end
|
306
315
|
|
307
|
-
|
316
|
+
def call_with_index(array, index, options, &block)
|
317
|
+
args = [array[index]]
|
318
|
+
args << index if options[:with_index]
|
319
|
+
block.call(*args)
|
320
|
+
end
|
321
|
+
|
322
|
+
def with_instrumentation(item, index, options)
|
323
|
+
on_start = options[:start]
|
324
|
+
on_finish = options[:finish]
|
325
|
+
on_start.call(item, index) if on_start
|
326
|
+
yield
|
327
|
+
ensure
|
328
|
+
on_finish.call(item, index) if on_finish
|
308
329
|
end
|
309
330
|
end
|
310
331
|
end
|
data/lib/parallel/version.rb
CHANGED
@@ -0,0 +1,18 @@
|
|
1
|
+
require File.expand_path('spec/spec_helper')
|
2
|
+
|
3
|
+
Parallel::Worker.class_eval do
|
4
|
+
alias_method :work_without_kill, :work
|
5
|
+
def work(*args)
|
6
|
+
Process.kill("SIGKILL", pid)
|
7
|
+
sleep 0.5
|
8
|
+
work_without_kill(*args)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
begin
|
13
|
+
Parallel.map([1,2,3]) do |x, i|
|
14
|
+
Process.kill("SIGKILL", Process.pid)
|
15
|
+
end
|
16
|
+
rescue Parallel::DeadWorker
|
17
|
+
puts "DEAD"
|
18
|
+
end
|
data/spec/parallel_spec.rb
CHANGED
@@ -206,6 +206,14 @@ describe Parallel do
|
|
206
206
|
monitor.should_receive(:call).once.with(:third, 2)
|
207
207
|
Parallel.map([:first, :second, :third], :finish => monitor, :in_threads => 3) {}
|
208
208
|
end
|
209
|
+
|
210
|
+
it "spits out a useful error when a worker dies before read" do
|
211
|
+
`ruby spec/cases/map_with_killed_worker_before_read.rb 2>&1`.should include "DEAD"
|
212
|
+
end
|
213
|
+
|
214
|
+
it "spits out a useful error when a worker dies before write" do
|
215
|
+
`ruby spec/cases/map_with_killed_worker_before_write.rb 2>&1`.should include "DEAD"
|
216
|
+
end
|
209
217
|
end
|
210
218
|
|
211
219
|
describe ".map_with_index" do
|
metadata
CHANGED
@@ -1,15 +1,15 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: parallel
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.5.21
|
5
4
|
prerelease:
|
5
|
+
version: 0.6.0
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
8
8
|
- Michael Grosser
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2012-12-
|
12
|
+
date: 2012-12-15 00:00:00.000000000 Z
|
13
13
|
dependencies: []
|
14
14
|
description:
|
15
15
|
email: michael@grosser.it
|
@@ -31,6 +31,8 @@ files:
|
|
31
31
|
- spec/cases/host_os_override_processor_count.rb
|
32
32
|
- spec/cases/map_with_index.rb
|
33
33
|
- spec/cases/map_with_index_empty.rb
|
34
|
+
- spec/cases/map_with_killed_worker_before_read.rb
|
35
|
+
- spec/cases/map_with_killed_worker_before_write.rb
|
34
36
|
- spec/cases/map_with_nested_arrays_and_nil.rb
|
35
37
|
- spec/cases/map_with_processes_and_exceptions.rb
|
36
38
|
- spec/cases/map_with_threads_and_exceptions.rb
|
@@ -59,23 +61,23 @@ rdoc_options: []
|
|
59
61
|
require_paths:
|
60
62
|
- lib
|
61
63
|
required_ruby_version: !ruby/object:Gem::Requirement
|
62
|
-
none: false
|
63
64
|
requirements:
|
64
65
|
- - ! '>='
|
65
66
|
- !ruby/object:Gem::Version
|
66
67
|
version: '0'
|
67
68
|
segments:
|
68
69
|
- 0
|
69
|
-
hash: -
|
70
|
-
required_rubygems_version: !ruby/object:Gem::Requirement
|
70
|
+
hash: -1821301885117501782
|
71
71
|
none: false
|
72
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
72
73
|
requirements:
|
73
74
|
- - ! '>='
|
74
75
|
- !ruby/object:Gem::Version
|
75
76
|
version: '0'
|
76
77
|
segments:
|
77
78
|
- 0
|
78
|
-
hash: -
|
79
|
+
hash: -1821301885117501782
|
80
|
+
none: false
|
79
81
|
requirements: []
|
80
82
|
rubyforge_project:
|
81
83
|
rubygems_version: 1.8.24
|