parallel 2.0.1 → 2.2.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.
- checksums.yaml +4 -4
- data/lib/parallel/serializer.rb +56 -0
- data/lib/parallel/version.rb +1 -1
- data/lib/parallel.rb +124 -61
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bcd30f5bfe7076b4a0872b389f00057efaae80917298e3d227c160cc8c1be410
|
|
4
|
+
data.tar.gz: d6dd66ee7f75d2e14123c292ce13ac2b448134622c712650c07482986dbd48a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 11ed4f49a53e9e20d052725911593266b808dfaa7b2392f503d39494b318a16df15ad628b586f93b700f5e22627116c465afff818d61ac2f0ca3aed4dcd1b8c0
|
|
7
|
+
data.tar.gz: af37003c9dc58ae81dac2a80355f5b16db9f54734f06a9c0dc33e73730818f7e81306237f152cd2976399a6453300915b19593e2e6928b480330fa736c516bc6
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'openssl'
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Parallel
|
|
6
|
+
# Pluggable wire serializers. Each must respond to `dump(data, io)` /
|
|
7
|
+
# `load(io)` (used directly by Worker) and `dump(data)` / `load(string)`
|
|
8
|
+
# (used by wrappers like Hmac).
|
|
9
|
+
module Serializer
|
|
10
|
+
# Raw Marshal. Fast but trusts anything written to the pipe — a same-UID
|
|
11
|
+
# attacker that reopens /proc/<pid>/fd/<n> can inject Marshal gadgets (RCE).
|
|
12
|
+
Marshal = ::Marshal
|
|
13
|
+
|
|
14
|
+
# Wraps any inner serializer with a length-prefixed HMAC-SHA256 frame keyed
|
|
15
|
+
# on a per-worker secret generated before fork. Forged frames from a
|
|
16
|
+
# pipe-injector fail verification.
|
|
17
|
+
class Hmac
|
|
18
|
+
LENGTH_FORMAT = 'N' # 32-bit big-endian unsigned int
|
|
19
|
+
LENGTH_BYTES = 4
|
|
20
|
+
MAC_BYTES = 32 # SHA256
|
|
21
|
+
|
|
22
|
+
def initialize(inner: Marshal, secret: SecureRandom.bytes(32))
|
|
23
|
+
@inner = inner
|
|
24
|
+
@secret = secret
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def inspect
|
|
28
|
+
"#<#{self.class} @inner=#{@inner.inspect}, @secret=[REDACTED]>"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def dump(data, io)
|
|
32
|
+
payload = @inner.dump(data)
|
|
33
|
+
mac = OpenSSL::HMAC.digest('SHA256', @secret, payload)
|
|
34
|
+
io.write([payload.bytesize].pack(LENGTH_FORMAT), mac, payload)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def load(io)
|
|
38
|
+
# nil at frame boundary = clean EOF (worker died / pipe closed between messages)
|
|
39
|
+
header = io.read(LENGTH_BYTES) || raise(EOFError) # eof stops worker
|
|
40
|
+
raise SecurityError, "truncated frame header" if header.bytesize != LENGTH_BYTES
|
|
41
|
+
|
|
42
|
+
length = header.unpack1(LENGTH_FORMAT)
|
|
43
|
+
mac = io.read(MAC_BYTES)
|
|
44
|
+
raise SecurityError, "truncated frame mac" if mac.nil? || mac.bytesize != MAC_BYTES
|
|
45
|
+
|
|
46
|
+
payload = io.read(length)
|
|
47
|
+
raise SecurityError, "truncated frame payload" if payload.nil? || payload.bytesize != length
|
|
48
|
+
|
|
49
|
+
expected = OpenSSL::HMAC.digest('SHA256', @secret, payload)
|
|
50
|
+
raise SecurityError, "HMAC mismatch on worker pipe" unless OpenSSL.fixed_length_secure_compare(mac, expected)
|
|
51
|
+
|
|
52
|
+
@inner.load(payload)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/parallel/version.rb
CHANGED
data/lib/parallel.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'rbconfig'
|
|
3
3
|
require 'parallel/version'
|
|
4
|
+
require 'parallel/serializer'
|
|
4
5
|
|
|
5
6
|
module Parallel
|
|
6
7
|
Stop = Object.new.freeze
|
|
@@ -63,10 +64,11 @@ module Parallel
|
|
|
63
64
|
attr_reader :pid, :read, :write
|
|
64
65
|
attr_accessor :thread
|
|
65
66
|
|
|
66
|
-
def initialize(read, write, pid)
|
|
67
|
+
def initialize(read, write, pid, serializer)
|
|
67
68
|
@read = read
|
|
68
69
|
@write = write
|
|
69
70
|
@pid = pid
|
|
71
|
+
@serializer = serializer
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
def stop
|
|
@@ -83,13 +85,13 @@ module Parallel
|
|
|
83
85
|
|
|
84
86
|
def work(data)
|
|
85
87
|
begin
|
|
86
|
-
|
|
88
|
+
@serializer.dump(data, write)
|
|
87
89
|
rescue Errno::EPIPE
|
|
88
90
|
raise DeadWorker
|
|
89
91
|
end
|
|
90
92
|
|
|
91
93
|
result = begin
|
|
92
|
-
|
|
94
|
+
@serializer.load(read)
|
|
93
95
|
rescue EOFError
|
|
94
96
|
raise DeadWorker
|
|
95
97
|
end
|
|
@@ -122,7 +124,7 @@ module Parallel
|
|
|
122
124
|
item, index = @mutex.synchronize do
|
|
123
125
|
return if @stopped
|
|
124
126
|
item = @lambda.call
|
|
125
|
-
@stopped = (item
|
|
127
|
+
@stopped = Stop.equal?(item)
|
|
126
128
|
return if @stopped
|
|
127
129
|
[item, @index += 1]
|
|
128
130
|
end
|
|
@@ -176,7 +178,7 @@ module Parallel
|
|
|
176
178
|
|
|
177
179
|
if @to_be_killed.empty?
|
|
178
180
|
old_interrupt = trap_interrupt(signal) do
|
|
179
|
-
warn '
|
|
181
|
+
warn 'parallel: execution interrupted, exiting ...'
|
|
180
182
|
@to_be_killed.flatten.each { |pid| kill(pid) }
|
|
181
183
|
end
|
|
182
184
|
end
|
|
@@ -243,7 +245,7 @@ module Parallel
|
|
|
243
245
|
end
|
|
244
246
|
|
|
245
247
|
def each(array, options = {}, &block)
|
|
246
|
-
map(array, options.merge(
|
|
248
|
+
map(array, options.merge(discard_results: true), &block)
|
|
247
249
|
end
|
|
248
250
|
|
|
249
251
|
def any?(*args, &block)
|
|
@@ -264,9 +266,15 @@ module Parallel
|
|
|
264
266
|
options = options.dup
|
|
265
267
|
options[:mutex] = Mutex.new
|
|
266
268
|
|
|
267
|
-
if options
|
|
268
|
-
raise ArgumentError, "
|
|
269
|
-
|
|
269
|
+
if options.slice(:in_processes, :in_threads, :in_ractors).size > 1
|
|
270
|
+
raise ArgumentError, "Use only one of `in_processes`, `in_threads`, or `in_ractors`."
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
if options[:in_ractors] ? block : options[:ractor]
|
|
274
|
+
raise ArgumentError, "use either in_ractors with :ractor or a not in_ractors and block"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
if RUBY_PLATFORM.include?('java') && !options[:in_processes]
|
|
270
278
|
method = :in_threads
|
|
271
279
|
size = options[method] || processor_count
|
|
272
280
|
elsif options[:in_threads]
|
|
@@ -280,31 +288,38 @@ module Parallel
|
|
|
280
288
|
if Process.respond_to?(:fork)
|
|
281
289
|
size = options[method] || processor_count
|
|
282
290
|
else
|
|
283
|
-
warn "Process.fork is not supported by this Ruby"
|
|
291
|
+
warn "parallel: Process.fork is not supported by this Ruby"
|
|
284
292
|
size = 0
|
|
285
293
|
end
|
|
286
294
|
end
|
|
287
295
|
|
|
296
|
+
raise ArgumentError, "worker count must be a non-negative Integer" unless size.is_a?(Integer) && size >= 0
|
|
297
|
+
|
|
288
298
|
job_factory = JobFactory.new(source, options[:mutex])
|
|
289
299
|
size = [job_factory.size, size].min
|
|
290
300
|
|
|
291
|
-
|
|
301
|
+
discard_results = options[:discard_results]
|
|
302
|
+
|
|
303
|
+
# finish callback needs the results, careful to do that before add_progress_bar which adds finish
|
|
304
|
+
options[:discard_results] = discard_results && !options[:finish]
|
|
305
|
+
|
|
292
306
|
add_progress_bar!(job_factory, options)
|
|
293
307
|
|
|
294
308
|
result =
|
|
295
309
|
if size == 0
|
|
310
|
+
block = ractor_block(options) if method == :in_ractors
|
|
296
311
|
work_direct(job_factory, options, &block)
|
|
297
312
|
elsif method == :in_threads
|
|
298
313
|
work_in_threads(job_factory, options.merge(count: size), &block)
|
|
299
314
|
elsif method == :in_ractors
|
|
300
|
-
work_in_ractors(job_factory, options.merge(count: size)
|
|
315
|
+
work_in_ractors(job_factory, options.merge(count: size))
|
|
301
316
|
else
|
|
302
317
|
work_in_processes(job_factory, options.merge(count: size), &block)
|
|
303
318
|
end
|
|
304
319
|
|
|
305
320
|
return result.value if result.is_a?(Break)
|
|
306
321
|
raise result if result.is_a?(Exception)
|
|
307
|
-
|
|
322
|
+
discard_results ? source : result
|
|
308
323
|
end
|
|
309
324
|
|
|
310
325
|
def map_with_index(array, options = {}, &block)
|
|
@@ -316,7 +331,7 @@ module Parallel
|
|
|
316
331
|
end
|
|
317
332
|
|
|
318
333
|
def filter_map(...)
|
|
319
|
-
map(...).
|
|
334
|
+
map(...).select { |value| value }
|
|
320
335
|
end
|
|
321
336
|
|
|
322
337
|
# Number of physical processor cores on the current system.
|
|
@@ -377,7 +392,7 @@ module Parallel
|
|
|
377
392
|
end
|
|
378
393
|
if !result || $?.exitstatus != 0
|
|
379
394
|
# Bail out if both commands returned something unexpected
|
|
380
|
-
warn "guessing
|
|
395
|
+
warn "parallel: guessing physical processor count"
|
|
381
396
|
processor_count
|
|
382
397
|
else
|
|
383
398
|
# powershell: "\nNumberOfCores\n-------------\n 4\n\n\n"
|
|
@@ -418,22 +433,24 @@ module Parallel
|
|
|
418
433
|
end
|
|
419
434
|
|
|
420
435
|
def work_direct(job_factory, options, &block)
|
|
436
|
+
previous_worker_number = worker_number
|
|
421
437
|
self.worker_number = 0
|
|
422
438
|
results = []
|
|
423
439
|
exception = nil
|
|
424
440
|
begin
|
|
425
441
|
while (set = job_factory.next)
|
|
426
442
|
item, index = set
|
|
427
|
-
|
|
443
|
+
result = with_instrumentation(item, index, options) do
|
|
428
444
|
call_with_index(item, index, options, &block)
|
|
429
445
|
end
|
|
446
|
+
results << result unless options[:discard_results]
|
|
430
447
|
end
|
|
431
448
|
rescue StandardError
|
|
432
449
|
exception = $!
|
|
433
450
|
end
|
|
434
451
|
exception || results
|
|
435
452
|
ensure
|
|
436
|
-
self.worker_number =
|
|
453
|
+
self.worker_number = previous_worker_number
|
|
437
454
|
end
|
|
438
455
|
|
|
439
456
|
def work_in_threads(job_factory, options, &block)
|
|
@@ -451,7 +468,7 @@ module Parallel
|
|
|
451
468
|
result = with_instrumentation item, index, options do
|
|
452
469
|
call_with_index(item, index, options, &block)
|
|
453
470
|
end
|
|
454
|
-
results_mutex.synchronize { results[index] = result }
|
|
471
|
+
results_mutex.synchronize { results[index] = result } unless options[:discard_results]
|
|
455
472
|
rescue StandardError
|
|
456
473
|
exception = $!
|
|
457
474
|
end
|
|
@@ -480,55 +497,70 @@ module Parallel
|
|
|
480
497
|
ports[port] = ractor
|
|
481
498
|
end
|
|
482
499
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
500
|
+
begin
|
|
501
|
+
# start by sending 1 item each
|
|
502
|
+
ports.dup.each do |port, ractor|
|
|
503
|
+
if (job = job_factory.next)
|
|
504
|
+
ractor_send ractor, callback, job, options
|
|
505
|
+
else # not enough work, `receive` would hang
|
|
506
|
+
ractor_stop ractor
|
|
507
|
+
ports.delete port
|
|
508
|
+
end
|
|
492
509
|
end
|
|
493
|
-
end
|
|
494
510
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
511
|
+
# receive result and send new items
|
|
512
|
+
while (job = job_factory.next)
|
|
513
|
+
# receive result
|
|
514
|
+
done_port, (exception, result, item_prev, index_prev) = Ractor.select(*ports.keys)
|
|
515
|
+
done_ractor = ports[done_port]
|
|
516
|
+
if exception
|
|
517
|
+
ractor_stop done_ractor
|
|
518
|
+
ports.delete done_port
|
|
519
|
+
break
|
|
520
|
+
end
|
|
521
|
+
ractor_result item_prev, index_prev, result, results, results_mutex, options
|
|
505
522
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
done_ractor.send([callback, item_next, index_next])
|
|
510
|
-
end
|
|
523
|
+
# send new
|
|
524
|
+
ractor_send done_ractor, callback, job, options
|
|
525
|
+
end
|
|
511
526
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
527
|
+
# finish by receiving the last results
|
|
528
|
+
ports.dup.each do |port, ractor|
|
|
529
|
+
(new_exception, result, item, index) = use_port ? port.receive : ractor.take
|
|
530
|
+
exception ||= new_exception
|
|
531
|
+
ractor_result item, index, result, results, results_mutex, options unless new_exception
|
|
532
|
+
ractor_stop ractor
|
|
533
|
+
ports.delete port
|
|
534
|
+
end
|
|
535
|
+
ensure # close any ports that remained open in case something blew up
|
|
536
|
+
ports.each_value do |ractor|
|
|
537
|
+
ractor_stop(ractor)
|
|
538
|
+
ractor.take unless use_port
|
|
539
|
+
rescue Ractor::ClosedError, Ractor::RemoteError
|
|
540
|
+
nil
|
|
541
|
+
end
|
|
519
542
|
end
|
|
520
543
|
|
|
521
544
|
exception || results
|
|
522
545
|
end
|
|
523
546
|
|
|
547
|
+
# ractors cannot execute blocks, so run the callback directly when not using ractors
|
|
548
|
+
def ractor_block(options)
|
|
549
|
+
klass, method_name = options[:ractor] ||
|
|
550
|
+
raise(ArgumentError, "pass the code you want to execute as `ractor: [ClassName, :method_name]`")
|
|
551
|
+
->(*args) { klass.send(method_name, *args) }
|
|
552
|
+
end
|
|
553
|
+
|
|
524
554
|
def ractor_build(use_port)
|
|
525
555
|
args = use_port ? [Ractor::Port.new] : []
|
|
526
556
|
ractor = Ractor.new(*args) do |port|
|
|
527
557
|
loop do
|
|
528
|
-
(klass, method_name), item, index = receive
|
|
558
|
+
(klass, method_name), item, index, with_index, discard_results = receive
|
|
529
559
|
break if index == :break
|
|
530
560
|
begin
|
|
531
|
-
|
|
561
|
+
value = with_index ? klass.send(method_name, item, index) : klass.send(method_name, item)
|
|
562
|
+
value = nil if discard_results
|
|
563
|
+
result = [nil, value, item, index]
|
|
532
564
|
rescue StandardError => e
|
|
533
565
|
result = [e, nil, item, index]
|
|
534
566
|
end
|
|
@@ -544,11 +576,19 @@ module Parallel
|
|
|
544
576
|
|
|
545
577
|
def ractor_result(item, index, result, results, results_mutex, options)
|
|
546
578
|
instrument_finish item, index, result, options
|
|
547
|
-
results_mutex.synchronize { results[index] =
|
|
579
|
+
results_mutex.synchronize { results[index] = result } unless options[:discard_results]
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def ractor_send(ractor, callback, job, options)
|
|
583
|
+
item, index = job
|
|
584
|
+
instrument_start item, index, options
|
|
585
|
+
ractor.send [callback, item, index, options[:with_index], options[:discard_results]]
|
|
548
586
|
end
|
|
549
587
|
|
|
550
588
|
def ractor_stop(ractor)
|
|
551
589
|
ractor.send([[nil, nil], nil, :break])
|
|
590
|
+
rescue Ractor::ClosedError
|
|
591
|
+
nil
|
|
552
592
|
end
|
|
553
593
|
|
|
554
594
|
def work_in_processes(job_factory, options, &blk)
|
|
@@ -579,7 +619,8 @@ module Parallel
|
|
|
579
619
|
result = with_instrumentation item, index, options do
|
|
580
620
|
worker.work(job_factory.pack(item, index))
|
|
581
621
|
end
|
|
582
|
-
|
|
622
|
+
# arrays are not threads safe on jRuby
|
|
623
|
+
results_mutex.synchronize { results[index] = result } unless options[:discard_results]
|
|
583
624
|
rescue StandardError => e
|
|
584
625
|
exception = e
|
|
585
626
|
if exception.is_a?(Kill)
|
|
@@ -617,11 +658,19 @@ module Parallel
|
|
|
617
658
|
workers << worker(job_factory, options.merge(started_workers: workers, worker_number: i), &block)
|
|
618
659
|
end
|
|
619
660
|
workers
|
|
661
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
662
|
+
workers.each do |worker|
|
|
663
|
+
worker.stop
|
|
664
|
+
rescue StandardError
|
|
665
|
+
nil
|
|
666
|
+
end
|
|
667
|
+
raise
|
|
620
668
|
end
|
|
621
669
|
|
|
622
670
|
def worker(job_factory, options, &block)
|
|
623
671
|
child_read, parent_write = IO.pipe
|
|
624
672
|
parent_read, child_write = IO.pipe
|
|
673
|
+
options[:serializer] ||= Serializer::Marshal
|
|
625
674
|
|
|
626
675
|
pid = Process.fork do
|
|
627
676
|
self.worker_number = options[:worker_number]
|
|
@@ -642,12 +691,26 @@ module Parallel
|
|
|
642
691
|
child_read.close
|
|
643
692
|
child_write.close
|
|
644
693
|
|
|
645
|
-
Worker.new(parent_read, parent_write, pid)
|
|
694
|
+
Worker.new(parent_read, parent_write, pid, options[:serializer])
|
|
695
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
696
|
+
[child_read, parent_write, parent_read, child_write].compact.each do |io|
|
|
697
|
+
io.close unless io.closed?
|
|
698
|
+
end
|
|
699
|
+
if pid
|
|
700
|
+
UserInterruptHandler.kill(pid)
|
|
701
|
+
begin
|
|
702
|
+
Process.wait(pid)
|
|
703
|
+
rescue Errno::ECHILD
|
|
704
|
+
nil
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
raise
|
|
646
708
|
end
|
|
647
709
|
|
|
648
710
|
def process_incoming_jobs(read, write, job_factory, options, &block)
|
|
711
|
+
serializer = options.fetch(:serializer)
|
|
649
712
|
until read.eof?
|
|
650
|
-
data =
|
|
713
|
+
data = serializer.load(read)
|
|
651
714
|
item, index = job_factory.unpack(data)
|
|
652
715
|
|
|
653
716
|
result =
|
|
@@ -661,7 +724,7 @@ module Parallel
|
|
|
661
724
|
end
|
|
662
725
|
|
|
663
726
|
begin
|
|
664
|
-
|
|
727
|
+
serializer.dump(result, write)
|
|
665
728
|
rescue Errno::EPIPE
|
|
666
729
|
return # parent thread already dead
|
|
667
730
|
end
|
|
@@ -683,10 +746,10 @@ module Parallel
|
|
|
683
746
|
args = [item]
|
|
684
747
|
args << index if options[:with_index]
|
|
685
748
|
results = block.call(*args)
|
|
686
|
-
if options[:
|
|
687
|
-
results
|
|
688
|
-
else
|
|
749
|
+
if options[:discard_results]
|
|
689
750
|
nil # avoid GC overhead of passing large results around
|
|
751
|
+
else
|
|
752
|
+
results
|
|
690
753
|
end
|
|
691
754
|
end
|
|
692
755
|
|
|
@@ -694,7 +757,7 @@ module Parallel
|
|
|
694
757
|
instrument_start(item, index, options)
|
|
695
758
|
result = yield
|
|
696
759
|
instrument_finish(item, index, result, options)
|
|
697
|
-
result unless options[:
|
|
760
|
+
result unless options[:discard_results]
|
|
698
761
|
end
|
|
699
762
|
|
|
700
763
|
def instrument_finish(item, index, result, options)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: parallel
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0
|
|
4
|
+
version: 2.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Grosser
|
|
@@ -16,16 +16,17 @@ extra_rdoc_files: []
|
|
|
16
16
|
files:
|
|
17
17
|
- MIT-LICENSE.txt
|
|
18
18
|
- lib/parallel.rb
|
|
19
|
+
- lib/parallel/serializer.rb
|
|
19
20
|
- lib/parallel/version.rb
|
|
20
21
|
homepage: https://github.com/grosser/parallel
|
|
21
22
|
licenses:
|
|
22
23
|
- MIT
|
|
23
24
|
metadata:
|
|
24
25
|
bug_tracker_uri: https://github.com/grosser/parallel/issues
|
|
25
|
-
documentation_uri: https://github.com/grosser/parallel/blob/v2.0
|
|
26
|
-
source_code_uri: https://github.com/grosser/parallel/tree/v2.0
|
|
26
|
+
documentation_uri: https://github.com/grosser/parallel/blob/v2.2.0/Readme.md
|
|
27
|
+
source_code_uri: https://github.com/grosser/parallel/tree/v2.2.0
|
|
27
28
|
wiki_uri: https://github.com/grosser/parallel/wiki
|
|
28
|
-
changelog_uri: https://github.com/grosser/parallel/blob/v2.0
|
|
29
|
+
changelog_uri: https://github.com/grosser/parallel/blob/v2.2.0/CHANGELOG.md
|
|
29
30
|
rubygems_mfa_required: 'true'
|
|
30
31
|
rdoc_options: []
|
|
31
32
|
require_paths:
|