progeny 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e16d4fb0220a00c09eba23fc3b5f43cf72facb831a8d70f8afb2e4c48507b964
4
+ data.tar.gz: 6f852ef9c5fb2f0dafd6a153cc9045b5d37021f189e7da18d2ec137b7d9d3338
5
+ SHA512:
6
+ metadata.gz: 2f808a8bcdae021ab23ef31ad18df2c3d39b359f9b3752bc5924e143a00757359e64ff0586c617b1e8908cec1bb5e0f9b1076834edce27595e1733f76ddcdbc6
7
+ data.tar.gz: 97bbb478f5c1ad1fabd55491784da44562d28d909be6f9c67586643f3e740fc1189e298cb2ea05a3ae46b0726a299f2d1cdba0307e19ffec31c83e3a41e7d049
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2023-05-03
4
+
5
+ - Initial release. Forked `posix-spawn` gem.
6
+
7
+ ### Changed
8
+
9
+ - `Progeny::Command#new` and `Progeny::Command#build` only take actual `Hash`
10
+ objects as the `options` argument. We will not call `#to_hash` on the object
11
+ like `POSIX::Spawn::Child` did.
12
+
data/LICENSE.txt ADDED
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2011 by Ryan Tomayko <r@tomayko.com>
2
+ and Aman Gupta <aman@tmm1.net>
3
+ Copyright (c) 2023 by Luan Vieira <luanv@me.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person ob-
6
+ taining a copy of this software and associated documentation
7
+ files (the "Software"), to deal in the Software without restric-
8
+ tion, including without limitation the rights to use, copy, modi-
9
+ fy, merge, publish, distribute, sublicense, and/or sell copies of
10
+ the Software, and to permit persons to whom the Software is fur-
11
+ nished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
18
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONIN-
19
+ FRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
21
+ ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
22
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # progeny
2
+
3
+ Spawn child processes with simple string based standard input/output/error
4
+ stream handling in an easy-to-use interface.
5
+
6
+ ## Progeny::Command
7
+
8
+ The `Progeny::Command` class includes logic for executing child processes and
9
+ reading/writing from their standard input, output, and error streams. It's
10
+ designed to take all input in a single string and provides all output as single
11
+ strings and is therefore not well-suited to streaming large quantities of data
12
+ in and out of commands. That said, it has some benefits:
13
+
14
+ - **Simple** - requires little code for simple stream input and capture.
15
+ - **Internally non-blocking** (using `select(2)`) - handles all pipe hang cases
16
+ due to exceeding `PIPE_BUF` limits on one or more streams.
17
+ - **Uses Ruby under the hood** - It leverages Ruby's `Open3#popen3` and `Process.spawn`
18
+ behind the scenes so it's widely supported and consistently getting performance updates.
19
+
20
+ `Progeny::Command` takes the [standard `Process::spawn`
21
+ arguments](https://ruby-doc.org/current/Process.html#method-c-spawn) when
22
+ instantiated, and runs the process to completion after writing all input and
23
+ reading all output:
24
+
25
+ ```ruby
26
+ require 'progeny'
27
+ child = Progeny::Command.new('git', '--help')
28
+ ```
29
+
30
+ Retrieve process output written to stdout / stderr, or inspect the process's
31
+ exit status:
32
+
33
+ ```ruby
34
+ child.out
35
+ # => "usage: git [--version] [--exec-path[=GIT_EXEC_PATH]]\n ..."
36
+ child.err
37
+ # => ""
38
+ child.status
39
+ # => #<Process::Status: pid=80718,exited(0)>
40
+ ```
41
+
42
+ Use the `:input` option to write data on the new process's stdin immediately
43
+ after spawning:
44
+
45
+ ```ruby
46
+ child = Progeny::Command.new('bc', :input => '40 + 2')
47
+ child.out
48
+ # => "42\n"
49
+ ```
50
+
51
+ Additional options can be used to specify the maximum output size (`:max`) and
52
+ time of execution (`:timeout`) before the child process is aborted. See the
53
+ `Progeny::Command` docs for more info.
54
+
55
+ #### Reading Partial Results
56
+
57
+ `Progeny::Command` spawns the process immediately when instantiated.
58
+ As a result, if it is interrupted by an exception (either from reaching the
59
+ maximum output size, the time limit, or another factor), it is not possible to
60
+ access the `out` or `err` results because the constructor did not complete.
61
+
62
+ If you want to get the `out` and `err` data that was available when the process
63
+ was interrupted, use the `Progeny::Command` alternate form to create the child
64
+ without immediately spawning the process. Call `exec!` to run the command at a
65
+ place where you can catch any exceptions:
66
+
67
+ ```ruby
68
+ child = Progeny::Command.build('git', 'log', :max => 100)
69
+ begin
70
+ child.exec!
71
+ rescue Progeny::MaximumOutputExceeded
72
+ # limit was reached
73
+ end
74
+ child.out
75
+ # => "commit fa54abe139fd045bf6dc1cc259c0f4c06a9285bb\n..."
76
+ ```
77
+
78
+ Please note that when the `MaximumOutputExceeded` exception is raised, the
79
+ actual combined `out` and `err` data may be a bit longer than the `:max`
80
+ value due to internal buffering.
81
+
82
+ ## Why fork posix-spawn
83
+
84
+ This gem is a fork of the
85
+ [`posix-spawn`](https://github.com/rtomayko/posix-spawn) gem. Originally,
86
+ `posix-spawn` was developed as a higher-performance alternative to Ruby's
87
+ built-in `Process.spawn` method. It achieved this by utilizing the
88
+ [`posix_spawn()`](https://man7.org/linux/man-pages/man3/posix_spawn.3.html)
89
+ system call, which resulted in significant performance improvements, as
90
+ demonstrated in the benchmarks below.
91
+
92
+ However, the performance advantage of `posix-spawn` diminished with the release
93
+ of Ruby 2.2. In this version, Ruby transitioned from using the `fork()` system
94
+ call to [`vfork()`](https://man7.org/linux/man-pages/man2/vfork.2.html), which
95
+ creates a new process without copying the parent process's page tables, leading
96
+ to enhanced performance.
97
+
98
+ The following benchmarks illustrate the performance comparison:
99
+
100
+ - Performance comparison at the time of `posix-spawn` creation (Ruby used `fork` + `exec`):
101
+ ![image](https://user-images.githubusercontent.com/20481048/235776984-048669d9-8949-4bf8-90e8-632984ae0516.png)
102
+
103
+ Source: [`posix-spawn` README](https://github.com/rtomayko/posix-spawn/blob/313f2abd4b9b5e737615178e0b353114481b9ab8/README.md#benchmarks)
104
+
105
+ - Current performance comparison (Ruby's built-in functionality is now more performant):
106
+ ![image](https://user-images.githubusercontent.com/20481048/235777021-7e7afb19-0f73-41f1-bbc9-9bc952581c4d.png)
107
+
108
+ Source: Generated with the script in [this gist](https://gist.github.com/luanzeba/a1ebe2497ed4e2fb6491fd1780a52440) on a Debian 11 (bullseye) x86_64 machine.
109
+
110
+ For that reason, we decided to delete all of the custom spawn implementations
111
+ in the original gem: `POSIX::Spawn#spawn`, `POSIX::Spawn#popen4`,
112
+ `Kernel#system`, and <code>Kernel#\`</code>.
113
+
114
+ However, we didn't want to completely remove our use of `posix-spawn` because
115
+ we really enjoy the interface provided by `POSIX::Spawn::Child`. That's how
116
+ `progeny` came to be. It maintains all of the functionality provided by
117
+ `POSIX::Spawn::Child` under a new namespace: `Progeny::Command`.
118
+
119
+ ## How to migrate from `posix-spawn` to `progeny`
120
+
121
+ 1. Remove all usage of `POSIX::Spawn` as a Mixin.
122
+ Progeny does not include a Mixin so if you're including `POSIX::Spawn` in any
123
+ classes like so:
124
+ ```ruby
125
+ require 'posix/spawn'
126
+
127
+ class YourSpawnerClass
128
+ include POSIX::Spawn
129
+
130
+ # [...]
131
+ end
132
+ ```
133
+
134
+ You will need to remove the include statements and replace any use of `#spawn`
135
+ with Ruby's native `Process.spawn` and `#popen4` with Open3's `#popen3`.
136
+ ```diff
137
+ - require 'posix/spawn'
138
+ + require 'open3'
139
+
140
+ class YourSpawnerClass
141
+ - include POSIX::Spawn
142
+
143
+ def speak(message)
144
+ - pid = spawn('echo', message)
145
+ + pid = Process.spawn('echo', message)
146
+ Process::waitpid(pid)
147
+ end
148
+
149
+ def calculate(expression)
150
+ - pid, in, out, err = popen4('bc')
151
+ + in, out, err, wait_thr = Open3.popen3('bc')
152
+ + pid = wait_thr[:pid] # if pid is needed
153
+ in.write(expression)
154
+ in.close
155
+ out.read
156
+ ensure
157
+ [in, out, err].each { |io| io.close if !io.closed? }
158
+ - Process::waitpid(pid)
159
+ - $?
160
+ + wait_thr.value
161
+ end
162
+ end
163
+ ```
164
+
165
+ 2. Find and replace in Gemfile
166
+ ```diff
167
+ - gem 'posix-spawn'
168
+ + gem 'progeny'
169
+ ```
170
+ 3. Find and replace `POSIX::Spawn::Child` with `Progeny::Command` and any `POSIX::Spawn` exceptions with `Progeny`
171
+ ```diff
172
+ class GitDiff
173
+ def compare(from_sha, to_sha)
174
+ - child = POSIX::Spawn::Child.new("git", "diff #{from_sha}..#{to_sha}")
175
+ + child = Progeny::Command.new("git", "diff #{from_sha}..#{to_sha}")
176
+ child.out
177
+ end
178
+
179
+ def compare_to_remote_head
180
+ - child = POSIX::Spawn::Child.build('git', 'diff origin/main HEAD')
181
+ + child = Progeny::Command.build('git', 'diff origin/main HEAD')
182
+ begin
183
+ child.exec!
184
+ - rescue POSIX::Spawn::MaximumOutputExceeded
185
+ + rescue Progeny::MaximumOutputExceeded
186
+ # limit was reached
187
+ end
188
+ child.out
189
+ end
190
+ end
191
+ ```
192
+
193
+ 4. Confirm all is working as expected
194
+ `bundle install` and make sure your tests are passing. If you encounter any
195
+ issues feel free to open an issue or a PR.
196
+
@@ -0,0 +1,272 @@
1
+ require "open3"
2
+
3
+ module Progeny
4
+ # Progeny::Command includes logic for executing child processes and
5
+ # reading/writing from their standard input, output, and error streams. It's
6
+ # designed to take all input in a single string and provides all output
7
+ # (stderr and stdout) as single strings and is therefore not well-suited
8
+ # to streaming large quantities of data in and out of commands.
9
+ #
10
+ # Create and run a process to completion:
11
+ #
12
+ # >> child = Progeny::Command.new('git', '--help')
13
+ #
14
+ # Retrieve stdout or stderr output:
15
+ #
16
+ # >> child.out
17
+ # => "usage: git [--version] [--exec-path[=GIT_EXEC_PATH]]\n ..."
18
+ # >> child.err
19
+ # => ""
20
+ #
21
+ # Check process exit status information:
22
+ #
23
+ # >> child.status
24
+ # => #<Process::Status: pid=80718,exited(0)>
25
+ #
26
+ # To write data on the new process's stdin immediately after spawning:
27
+ #
28
+ # >> child = Progeny::Command.new('bc', :input => '40 + 2')
29
+ # >> child.out
30
+ # "42\n"
31
+ #
32
+ # To access output from the process even if an exception was raised:
33
+ #
34
+ # >> child = Progeny::Command.build('git', 'log', :max => 1000)
35
+ # >> begin
36
+ # ?> child.exec!
37
+ # ?> rescue Progeny::MaximumOutputExceeded
38
+ # ?> # just so you know
39
+ # ?> end
40
+ # >> child.out
41
+ # "... first 1000 characters of log output ..."
42
+
43
+ ##
44
+ # Exception raised when the total number of bytes output on the command's
45
+ # stderr and stdout streams exceeds the maximum output size (:max option).
46
+ class MaximumOutputExceeded < StandardError
47
+ end
48
+
49
+ # Exception raised when timeout is exceeded.
50
+ class TimeoutExceeded < StandardError
51
+ end
52
+
53
+ class Command
54
+ # Spawn a new process, write all input and read all output, and wait for
55
+ # the program to exit. Supports the standard spawn interface:
56
+ # new([env], command, [argv1, ...], [options])
57
+ #
58
+ # The following options are supported in addition to the standard
59
+ # Process.spawn options:
60
+ #
61
+ # :input => str Write str to the new process's standard input.
62
+ # :timeout => int Maximum number of seconds to allow the process
63
+ # to execute before aborting with a TimeoutExceeded
64
+ # exception.
65
+ # :max => total Maximum number of bytes of output to allow the
66
+ # process to generate before aborting with a
67
+ # MaximumOutputExceeded exception.
68
+ # :pgroup_kill => bool Boolean specifying whether to kill the process
69
+ # group (true) or individual process (false, default).
70
+ # Setting this option true implies :pgroup => true.
71
+ #
72
+ # Returns a new Command instance whose underlying process has already
73
+ # executed to completion. The out, err, and status attributes are
74
+ # immediately available.
75
+ def initialize(*args)
76
+ if args.last.is_a?(Hash)
77
+ options = args.pop.dup
78
+ else
79
+ options = {}
80
+ end
81
+
82
+ if args.first.is_a?(Hash)
83
+ @env = args.shift
84
+ else
85
+ @env = {}
86
+ end
87
+ @env.merge!(options.delete(:env)) if options.key?(:env)
88
+ @argv = args
89
+ @options = options.dup
90
+ @input = @options.delete(:input)
91
+ @timeout = @options.delete(:timeout)
92
+ @max = @options.delete(:max)
93
+ if @options.delete(:pgroup_kill)
94
+ @pgroup_kill = true
95
+ @options[:pgroup] = true
96
+ end
97
+ @options.delete(:chdir) if @options[:chdir].nil?
98
+ exec! if !@options.delete(:noexec)
99
+ end
100
+
101
+ # Set up a new process to spawn, but do not actually spawn it.
102
+ #
103
+ # Invoke this just like the normal constructor to set up a process
104
+ # to be run. Call `exec!` to actually run the child process, send
105
+ # the input, read the output, and wait for completion. Use this
106
+ # alternative way of constructing a Progency::Command if you want
107
+ # to read any partial output from the child process even after an
108
+ # exception.
109
+ #
110
+ # child = Progency::Command.build(... arguments ...)
111
+ # child.exec!
112
+ #
113
+ # The arguments are the same as the regular constructor.
114
+ #
115
+ # Returns a new Command instance but does not run the underlying process.
116
+ def self.build(*args)
117
+ options =
118
+ if args.last.is_a?(Hash)
119
+ args.pop.dup
120
+ else
121
+ {}
122
+ end
123
+ new(*(args + [{ :noexec => true }.merge(options)]))
124
+ end
125
+
126
+ # All data written to the child process's stdout stream as a String.
127
+ attr_reader :out
128
+
129
+ # All data written to the child process's stderr stream as a String.
130
+ attr_reader :err
131
+
132
+ # A Process::Status object with information on how the child exited.
133
+ attr_reader :status
134
+
135
+ # Total command execution time (wall-clock time)
136
+ attr_reader :runtime
137
+
138
+ # The pid of the spawned child process. This is unlikely to be a valid
139
+ # current pid since Command#exec! doesn't return until the process finishes
140
+ # and is reaped.
141
+ attr_reader :pid
142
+
143
+ # Determine if the process did exit with a zero exit status.
144
+ def success?
145
+ @status && @status.success?
146
+ end
147
+
148
+ # Execute command, write input, and read output. This is called
149
+ # immediately when a new instance of this object is created, or
150
+ # can be called explicitly when creating the Command via `build`.
151
+ def exec!
152
+ stdin, stdout, stderr, wait_thread = Open3.popen3(@env, *(@argv + [@options]))
153
+ @pid = wait_thread[:pid]
154
+
155
+ # async read from all streams into buffers
156
+ read_and_write(@input, stdin, stdout, stderr, @timeout, @max)
157
+
158
+ # wait_thr.value waits for the termination of the process and returns exit status
159
+ @status = wait_thread.value
160
+ rescue Object
161
+ [stdin, stdout, stderr].each { |fd| fd.close rescue nil }
162
+ if @status.nil?
163
+ if !@pgroup_kill
164
+ ::Process.kill('TERM', pid) rescue nil
165
+ else
166
+ ::Process.kill('-TERM', pid) rescue nil
167
+ end
168
+ @status = wait_thread.value rescue nil
169
+ end
170
+ raise
171
+ ensure
172
+ # let's be absolutely certain these are closed
173
+ [stdin, stdout, stderr].each { |fd| fd.close rescue nil }
174
+ end
175
+
176
+ private
177
+ # Maximum buffer size for reading
178
+ BUFSIZE = (32 * 1024)
179
+
180
+ # Start a select loop writing any input on the child's stdin and reading
181
+ # any output from the child's stdout or stderr.
182
+ #
183
+ # input - String input to write on stdin. May be nil.
184
+ # stdin - The write side IO object for the child's stdin stream.
185
+ # stdout - The read side IO object for the child's stdout stream.
186
+ # stderr - The read side IO object for the child's stderr stream.
187
+ # timeout - An optional Numeric specifying the total number of seconds
188
+ # the read/write operations should occur for.
189
+ #
190
+ # Returns an [out, err] tuple where both elements are strings with all
191
+ # data written to the stdout and stderr streams, respectively.
192
+ # Raises TimeoutExceeded when all data has not been read / written within
193
+ # the duration specified in the timeout argument.
194
+ # Raises MaximumOutputExceeded when the total number of bytes output
195
+ # exceeds the amount specified by the max argument.
196
+ def read_and_write(input, stdin, stdout, stderr, timeout=nil, max=nil)
197
+ max = nil if max && max <= 0
198
+ @out, @err = '', ''
199
+
200
+ # force all string and IO encodings to BINARY under 1.9 for now
201
+ if @out.respond_to?(:force_encoding) and stdin.respond_to?(:set_encoding)
202
+ [stdin, stdout, stderr].each do |fd|
203
+ fd.set_encoding('BINARY', 'BINARY')
204
+ end
205
+ @out.force_encoding('BINARY')
206
+ @err.force_encoding('BINARY')
207
+ input = input.dup.force_encoding('BINARY') if input
208
+ end
209
+
210
+ timeout = nil if timeout && timeout <= 0.0
211
+ @runtime = 0.0
212
+ start = Time.now
213
+
214
+ readers = [stdout, stderr]
215
+ writers =
216
+ if input
217
+ [stdin]
218
+ else
219
+ stdin.close
220
+ []
221
+ end
222
+ slice_method = input.respond_to?(:byteslice) ? :byteslice : :slice
223
+ t = timeout
224
+
225
+ while readers.any? || writers.any?
226
+ ready = IO.select(readers, writers, readers + writers, t)
227
+ raise TimeoutExceeded if ready.nil?
228
+
229
+ # write to stdin stream
230
+ ready[1].each do |fd|
231
+ begin
232
+ boom = nil
233
+ size = fd.write_nonblock(input)
234
+ input = input.send(slice_method, size..-1)
235
+ rescue Errno::EPIPE => boom
236
+ rescue Errno::EAGAIN, Errno::EINTR
237
+ end
238
+ if boom || input.bytesize == 0
239
+ stdin.close
240
+ writers.delete(stdin)
241
+ end
242
+ end
243
+
244
+ # read from stdout and stderr streams
245
+ ready[0].each do |fd|
246
+ buf = (fd == stdout) ? @out : @err
247
+ begin
248
+ buf << fd.readpartial(BUFSIZE)
249
+ rescue Errno::EAGAIN, Errno::EINTR
250
+ rescue EOFError
251
+ readers.delete(fd)
252
+ fd.close
253
+ end
254
+ end
255
+
256
+ # keep tabs on the total amount of time we've spent here
257
+ @runtime = Time.now - start
258
+ if timeout
259
+ t = timeout - @runtime
260
+ raise TimeoutExceeded if t < 0.0
261
+ end
262
+
263
+ # maybe we've hit our max output
264
+ if max && ready[0].any? && (@out.size + @err.size) > max
265
+ raise MaximumOutputExceeded
266
+ end
267
+ end
268
+
269
+ [@out, @err]
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,3 @@
1
+ module Progeny
2
+ VERSION = '0.1.0'
3
+ end
data/lib/progeny.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "progeny/version"
4
+ require_relative "progeny/command"
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: progeny
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Luan Vieira
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-05-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: Spawn child processes without managing IO streams, zombie processes and
42
+ other details.
43
+ email:
44
+ - luanv@me.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE.txt
51
+ - README.md
52
+ - lib/progeny.rb
53
+ - lib/progeny/command.rb
54
+ - lib/progeny/version.rb
55
+ homepage: https://github.com/luanzeba/progeny
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ homepage_uri: https://github.com/luanzeba/progeny
60
+ source_code_uri: https://github.com/luanzeba/progeny
61
+ changelog_uri: https://github.com/luanzeba/progeny/blob/main/CHANGELOG.md
62
+ post_install_message:
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: 2.7.0
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.4.10
78
+ signing_key:
79
+ specification_version: 4
80
+ summary: A popen3 wrapper with a nice interface and extra options.
81
+ test_files: []