process_executer 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86a47c55d9ddea5ca45bd4126af5e2e09a50348e44d65ef089e6f4eab69eb302
4
- data.tar.gz: 06b0a7ea4a9a134b5554b4dd673ecc087cf21d255e57dd18aca164644e4df644
3
+ metadata.gz: ae4d5881afe09475a15692bf7110746ff4dfd76a70958775351c6fb510a7fb2f
4
+ data.tar.gz: 683508220b6137f2129bf094b823862ae155a7a5b418aced87dd0056bb0eda7f
5
5
  SHA512:
6
- metadata.gz: 5625c64aee302c872b097ffd4cf88ddaf3b28ebcfd5d1cfc52dacd53ab9ef4cbb323b7d6b985db3038ce2f86eb2891deb6245d7b047bd219e35d09415fa15fa0
7
- data.tar.gz: 970147ff7974931dacaa0701ca32f5abcc540ade99ed8ca2b1b70b73cfa215bb09540931fc182fb67db13cbc920e17e3b55c9d07180fea98ed6bc0571aed6568
6
+ metadata.gz: 506898e021e1bf3b923d3900fb83005836e729daf064885c0d8172a35690fc9f7865d716197ce60cfb6d6a5153817bde385f6b5b38165296b7f1c213a7a6cfe3
7
+ data.tar.gz: 3111bfbaf30068de540fb1463a8f862a1d55d6c26f3c47cc90776b2d111a3330601927d4c7e70dcc719ea7c6e60da447c6a2b9b6bddeb89749f44e106a71847e
data/CHANGELOG.md CHANGED
@@ -1,6 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to the process_executer gem will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## v0.3.0 (2022-12-01)
9
+
10
+ [Full Changelog](https://github.com/main-branch/process_executer/compare/v0.2.0...v0.3.0)
11
+
12
+ * 6e2cdf1 Completely refactor to a single ProcessExecuter.spawn method (#7)
13
+ * 6da57ec Add CodeClimate badges to README.md (#6)
14
+ * eebd6ae Add front matter and v0.1.0 release to changelog (#5)
15
+ * 78cb9e5 Release v0.2.0
16
+
1
17
  ## v0.2.0 (2022-11-16)
2
18
 
3
19
  [Full Changelog](https://github.com/main-branch/process_executer/compare/v0.1.0...v0.2.0)
4
20
 
5
21
  * 8b70ac0 Use the create_github_release gem to make the release PR (#2)
6
22
  * 4b2700e Add ProcessExecuter#execute to execute a command and return the result (#1)
23
+
24
+ ## v0.1.0 (2022-10-20)
25
+
26
+ Initial release of an empty project
data/README.md CHANGED
@@ -1,6 +1,67 @@
1
1
  # The ProcessExecuter Gem
2
2
 
3
- An API for executing commands in a subprocess
3
+ [![Gem Version](https://badge.fury.io/rb/process_executer.svg)](https://badge.fury.io/rb/process_executer)
4
+ [![Build Status](https://github.com/main-branch/process_executer/workflows/Ruby/badge.svg?branch=main)](https://github.com/main-branch/process_executer/actions?query=workflow%3ARuby)
5
+ [![Maintainability](https://api.codeclimate.com/v1/badges/0b5c67e5c2a773009cd0/maintainability)](https://codeclimate.com/github/main-branch/process_executer/maintainability)
6
+ [![Test Coverage](https://api.codeclimate.com/v1/badges/0b5c67e5c2a773009cd0/test_coverage)](https://codeclimate.com/github/main-branch/process_executer/test_coverage)
7
+
8
+ ## Features
9
+
10
+ This gem contains the following features:
11
+
12
+ ### ProcessExecuter::MonitoredPipe
13
+
14
+ `ProcessExecuter::MonitoredPipe` streams data sent through a pipe to one or more writers.
15
+
16
+ When a new `MonitoredPipe` is created, an pipe is created (via IO.pipe) and
17
+ a thread is created which reads data as it is written written to the pipe.
18
+
19
+ Data that is read from the pipe is written one or more writers passed to
20
+ `MonitoredPipe#initialize`.
21
+
22
+ This is useful for streaming process output (stdout and/or stderr) to anything that has a
23
+ `#write` method: a string buffer, a file, or stdout/stderr as seen in the following example:
24
+
25
+ ```ruby
26
+ require 'stringio'
27
+ require 'process_executer'
28
+
29
+ output_buffer = StringIO.new
30
+ out_pipe = ProcessExecuter::MonitoredPipe.new(output_buffer)
31
+ pid, status = Process.wait2(Process.spawn('echo "Hello World"', out: out_pipe))
32
+ output_buffer.string #=> "Hello World\n"
33
+ ```
34
+
35
+ `MonitoredPipe#initialize` can take more than one writer so that pipe output can be
36
+ streamed (or `tee`d) to multiple writers at the same time:
37
+
38
+ ```ruby
39
+ require 'stringio'
40
+ require 'process_executer'
41
+
42
+ output_buffer = StringIO.new
43
+ output_file = File.open('process.out', 'w')
44
+ out_pipe = ProcessExecuter::MonitoredPipe.new(output_buffer, output_file)
45
+ pid, status = Process.wait2(Process.spawn('echo "Hello World"', out: out_pipe))
46
+ output_file.close
47
+ output_buffer.string #=> "Hello World\n"
48
+ File.read('process.out') #=> "Hello World\n"
49
+ ```
50
+
51
+ Since the data is streamed, any object that implements `#write` can be used. For insance,
52
+ you can use it to parse process output as a stream which might be useful for long XML
53
+ or JSON output.
54
+
55
+ ### ProcessExecuter.spawn
56
+
57
+ `ProcessExecuter.spawn` has the same interface as `Process.spawn` but has two
58
+ important behaviorial differences:
59
+
60
+ 1. It blocks until the subprocess finishes
61
+ 2. A timeout can be specified using the `:timeout` option
62
+
63
+ If the command does not terminate before the timeout, the process is killed by
64
+ sending it the SIGKILL signal.
4
65
 
5
66
  ## Installation
6
67
 
@@ -37,27 +98,6 @@ commits and the created tag, and push the `.gem` file to
37
98
  Bug reports and pull requests are welcome on our
38
99
  [GitHub issue tracker](https://github.com/main-branch/process_executer)
39
100
 
40
- ## Feature Checklist
41
-
42
- Here is the 1.0 feature checklist:
43
-
44
- * [x] Run a command
45
- * [x] Collect the command's stdout/stderr to a string
46
- * [x] Passthru the command's stdout/stderr to this process's stdout/stderr
47
- * [ ] Command execution timeout
48
- * [ ] Redirect stdout/stderr to a named file
49
- * [ ] Redirect stdout/stderr to a named file with open mode
50
- * [ ] Redirect stdout/stderr to a named file with open mode and permissions
51
- * [ ] Redirect stdout/stderr to an open File object
52
- * [ ] Merge stdout & stderr
53
- * [ ] Redirect a file to stdin
54
- * [ ] Redirect from a butter to stdin
55
- * [ ] Binary vs. text mode for stdin/stdout/stderr
56
- * [ ] Environment isolation like Process.spawn
57
- * [ ] Pass options to Process.spawn (chdir, umask, pgroup, etc.)
58
- * [ ] Don't allow optionis to Process.spawn that would break the functionality
59
- (:in, :out, :err, integer, #fileno, :close_others)
60
-
61
101
  ## License
62
102
 
63
103
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require 'io/wait'
5
+
6
+ module ProcessExecuter
7
+ # Stream data sent through a pipe to one or more writers
8
+ #
9
+ # When a new MonitoredPipe is created, a pipe is created (via IO.pipe) and
10
+ # a thread is created to read data written to the pipe.
11
+ #
12
+ # Data that is read from the pipe is written one or more writers passed to
13
+ # `#initialize`.
14
+ #
15
+ # `#close` must be called to ensure that (1) the pipe is closed, (2) all data is
16
+ # read from the pipe and written to the writers, and (3) the monitoring thread is
17
+ # killed.
18
+ #
19
+ # @example Collect pipe data into a string
20
+ # pipe_data = StringIO.new
21
+ # begin
22
+ # pipe = MonitoredPipe.new(pipe_data)
23
+ # pipe.write("Hello World")
24
+ # ensure
25
+ # pipe.close
26
+ # end
27
+ # pipe_data.string #=> "Hello World"
28
+ #
29
+ # @example Collect pipe data into a string AND a file
30
+ # pipe_data_string = StringIO.new
31
+ # pipe_data_file = File.open("pipe_data.txt", "w")
32
+ # begin
33
+ # pipe = MonitoredPipe.new(pipe_data_string, pipe_data_file)
34
+ # pipe.write("Hello World")
35
+ # ensure
36
+ # pipe.close
37
+ # end
38
+ # pipe_data_string.string #=> "Hello World"
39
+ # File.read("pipe_data.txt") #=> "Hello World"
40
+ #
41
+ # @api public
42
+ #
43
+ class MonitoredPipe
44
+ # Create a new monitored pipe
45
+ #
46
+ # Creates a IO.pipe and starts a monitoring thread to read data written to the pipe.
47
+ #
48
+ # @example
49
+ # data_collector = StringIO.new
50
+ # pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
51
+ #
52
+ # @param writers [Array<#write>] as data is read from the pipe, it is written to these writers
53
+ # @param chunk_size [Integer] the size of the chunks to read from the pipe
54
+ #
55
+ def initialize(*writers, chunk_size: 1000)
56
+ @pipe_reader, @pipe_writer = IO.pipe
57
+ @chunk_size = chunk_size
58
+ @writers = writers
59
+ @thread = Thread.new { monitor_pipe }
60
+ end
61
+
62
+ # Kill the monitoring thread, read remaining data, and close the pipe
63
+ #
64
+ # @example
65
+ # require 'stringio'
66
+ # data_collector = StringIO.new
67
+ # pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
68
+ # pipe.write('Hello World')
69
+ # pipe.close
70
+ # data_collector.string #=> "Hello World"
71
+ #
72
+ # @return [void]
73
+ #
74
+ def close
75
+ thread.kill
76
+ thread.join
77
+ pipe_writer.close
78
+ read_pipe_output if pipe_reader.wait_readable(0)
79
+ pipe_reader.close
80
+ end
81
+
82
+ # Return the write end of the pipe so that data can be written to it
83
+ #
84
+ # Data written to this end of the pipe will be read by the monitor thread and
85
+ # written to the writers passed to `#initialize`.
86
+ #
87
+ # This is so we can provide a MonitoredPipe to Process.spawn as a FD
88
+ #
89
+ # @example
90
+ # require 'stringio'
91
+ # data_collector = StringIO.new
92
+ # pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
93
+ # pipe.to_io.write('Hello World')
94
+ # pipe.close
95
+ # data_collector.string #=> "Hello World"
96
+ #
97
+ # @return [IO] the write end of the pipe
98
+ #
99
+ # @api private
100
+ #
101
+ def to_io
102
+ pipe_writer
103
+ end
104
+
105
+ # @!attribute [r] fileno
106
+ #
107
+ # The file descriptor for the write end of the pipe
108
+ #
109
+ # @example
110
+ # require 'stringio'
111
+ # data_collector = StringIO.new
112
+ # pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
113
+ # pipe.fileno == pipe.to_io.fileno #=> true
114
+ #
115
+ # @return [Integer] the file descriptor for the write end of the pipe
116
+ #
117
+ # @api private
118
+ #
119
+ def fileno
120
+ pipe_writer.fileno
121
+ end
122
+
123
+ # Writes data to the pipe so that it can be read by the monitor thread
124
+ #
125
+ # Primarily used for testing.
126
+ #
127
+ # @example
128
+ # require 'stringio'
129
+ # data_collector = StringIO.new
130
+ # pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
131
+ # pipe.write('Hello World')
132
+ # pipe.close
133
+ # data_collector.string #=> "Hello World"
134
+ #
135
+ # @param data [String] the data to write to the pipe
136
+ #
137
+ # @return [Integer] the number of bytes written to the pipe
138
+ #
139
+ # @api private
140
+ #
141
+ def write(data)
142
+ pipe_writer.write(data)
143
+ end
144
+
145
+ # @!attribute [r]
146
+ #
147
+ # The size of the chunks to read from the pipe
148
+ #
149
+ # @example
150
+ # require 'stringio'
151
+ # data_collector = StringIO.new
152
+ # pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
153
+ # pipe.chunk_size #=> 1000
154
+ #
155
+ # @return [Integer] the size of the chunks to read from the pipe
156
+ #
157
+ attr_reader :chunk_size
158
+
159
+ # @!attribute [r]
160
+ #
161
+ # An array of writers to write data that is read from the pipe
162
+ #
163
+ # @example with one writer
164
+ # require 'stringio'
165
+ # data_collector = StringIO.new
166
+ # pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
167
+ # pipe.writers #=> [data_collector]
168
+ #
169
+ # @example with an array of writers
170
+ # require 'stringio'
171
+ # data_collector1 = StringIO.new
172
+ # data_collector2 = StringIO.new
173
+ # pipe = ProcessExecuter::MonitoredPipe.new(data_collector1, data_collector2)
174
+ # pipe.writers #=> [data_collector1, data_collector2]]
175
+ #
176
+ # @return [Array<#write>]
177
+ #
178
+ attr_reader :writers
179
+
180
+ # @!attribute [r]
181
+ #
182
+ # The thread that monitors the pipe
183
+ #
184
+ # @example
185
+ # require 'stringio'
186
+ # data_collector = StringIO.new
187
+ # pipe = ProcessExecuter::MonitoredPipe.new(data_collector)
188
+ # pipe.thread #=> #<Thread:0x00007f8b1a0b0e00>
189
+ #
190
+ # @return [Thread]
191
+ attr_reader :thread
192
+
193
+ # @!attribute [r]
194
+ #
195
+ # The read end of the pipe
196
+ #
197
+ # @example
198
+ # pipe = ProcessExecuter::MonitoredPipe.new($stdout)
199
+ # pipe.pipe_reader #=> #<IO:fd 11>
200
+ #
201
+ # @return [IO]
202
+ attr_reader :pipe_reader
203
+
204
+ # @!attribute [r]
205
+ #
206
+ # The write end of the pipe
207
+ #
208
+ # @example
209
+ # pipe = ProcessExecuter::MonitoredPipe.new($stdout)
210
+ # pipe.pipe_writer #=> #<IO:fd 12>
211
+ #
212
+ # @return [IO] the write end of the pipe
213
+ attr_reader :pipe_writer
214
+
215
+ private
216
+
217
+ # Reads data from the pipe forever until the monitoring thread is killed
218
+ # @return [void]
219
+ # @api private
220
+ def monitor_pipe
221
+ loop do
222
+ read_pipe_output if pipe_reader.wait_readable
223
+ end
224
+ end
225
+
226
+ # Read a chunk of data from the pipe and write it to the writers
227
+ # @return [void]
228
+ # @api private
229
+ def read_pipe_output
230
+ new_data = pipe_reader.read_nonblock(chunk_size)
231
+ # puts "Received new data: #{new_data.inspect} from #{pipe_reader.inspect}"
232
+ writers.each { |w| w.write(new_data) }
233
+ rescue EOFError, IO::EAGAINWaitReadable
234
+ # No output to read at this time
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'ostruct'
5
+
6
+ module ProcessExecuter
7
+ # Validate ProcessExecuter::Executer#spawn options and return Process.spawn options
8
+ #
9
+ # Valid options are those accepted by Process.spawn plus the following additions:
10
+ #
11
+ # * `:timeout`:
12
+ #
13
+ # @api public
14
+ #
15
+ class Options
16
+ # These options should be passed to `Process.spawn`
17
+ #
18
+ # Additionally, any options whose key is an Integer or an IO object will
19
+ # be passed to `Process.spawn`.
20
+ #
21
+ SPAWN_OPTIONS = %i[
22
+ in out err unsetenv_others pgroup new_pgroup rlimit_resourcename umask
23
+ close_others chdir
24
+ ].freeze
25
+
26
+ # These options are allowed but should NOT be passed to `Process.spawn`
27
+ #
28
+ NON_SPAWN_OPTIONS = %i[
29
+ timeout
30
+ ].freeze
31
+
32
+ # Any `SPAWN_OPTIONS`` set to this value will not be passed to `Process.spawn`
33
+ #
34
+ NOT_SET = :not_set
35
+
36
+ # The default values for all options
37
+ # @return [Hash]
38
+ DEFAULTS = {
39
+ in: NOT_SET,
40
+ out: NOT_SET,
41
+ err: NOT_SET,
42
+ unsetenv_others: NOT_SET,
43
+ pgroup: NOT_SET,
44
+ new_pgroup: NOT_SET,
45
+ rlimit_resourcename: NOT_SET,
46
+ umask: NOT_SET,
47
+ close_others: NOT_SET,
48
+ chdir: NOT_SET,
49
+ timeout: nil
50
+ }.freeze
51
+
52
+ # All options allowed by this class
53
+ #
54
+ ALL_OPTIONS = (SPAWN_OPTIONS + NON_SPAWN_OPTIONS).freeze
55
+
56
+ # Create accessor functions for all options. Assumes that the options are stored
57
+ # in a hash named `@options`
58
+ #
59
+ ALL_OPTIONS.each do |option|
60
+ define_method(option) do
61
+ @options[option]
62
+ end
63
+ end
64
+
65
+ # Create a new Options object
66
+ #
67
+ # @example
68
+ # options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout: 10)
69
+ #
70
+ # @param options [Hash] Process.spawn options plus additional options listed below.
71
+ #
72
+ # See [Process.spawn](https://ruby-doc.org/core/Process.html#method-c-spawn)
73
+ # for a list of valid.
74
+ #
75
+ # @option options [Integer, Float, nil] :timeout
76
+ # Number of seconds to wait for the process to terminate. Any number
77
+ # may be used, including Floats to specify fractional seconds. A value of 0 or nil
78
+ # will allow the process to run indefinitely.
79
+ #
80
+ def initialize(**options)
81
+ assert_no_unknown_options(options)
82
+ @options = DEFAULTS.merge(options)
83
+ end
84
+
85
+ # Returns the options to be passed to Process.spawn
86
+ #
87
+ # @example
88
+ # options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout: 10)
89
+ # options.spawn_options # => { out: $stdout, err: $stderr }
90
+ #
91
+ # @return [Hash]
92
+ #
93
+ def spawn_options
94
+ {}.tap do |spawn_options|
95
+ options.each do |option, value|
96
+ spawn_options[option] = value if include_spawn_option?(option, value)
97
+ end
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ # @!attribute [r]
104
+ #
105
+ # Options with values
106
+ #
107
+ # All options have values. If an option is not given in the initializer, it
108
+ # will have the value `NOT_SET`.
109
+ #
110
+ # @return [Hash<Symbol, Object>]
111
+ #
112
+ # @api private
113
+ #
114
+ attr_reader :options
115
+
116
+ # Determine if the options hash contains any unknown options
117
+ # @param options [Hash] the hash of options
118
+ # @return [void]
119
+ # @raise [ArgumentError] if the options hash contains any unknown options
120
+ # @api private
121
+ def assert_no_unknown_options(options)
122
+ unknown_options = options.keys.reject { |key| valid_option?(key) }
123
+ raise ArgumentError, "Unknown options: #{unknown_options.join(', ')}" unless unknown_options.empty?
124
+ end
125
+
126
+ # Determine if the given option is a valid option
127
+ # @param option [Symbol] the option to be tested
128
+ # @return [Boolean] true if the given option is a valid option
129
+ # @api private
130
+ def valid_option?(option)
131
+ ALL_OPTIONS.include?(option) || option.is_a?(Integer) || option.respond_to?(:fileno)
132
+ end
133
+
134
+ # Determine if the given option should be passed to `Process.spawn`
135
+ # @param option [Symbol, Integer, IO] the option to be tested
136
+ # @param value [Object] the value of the option
137
+ # @return [Boolean] true if the given option should be passed to `Process.spawn`
138
+ # @api private
139
+ def include_spawn_option?(option, value)
140
+ (option.is_a?(Integer) || option.is_a?(IO) || SPAWN_OPTIONS.include?(option)) && value != NOT_SET
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessExecuter
4
+ # Spawns a process and knows how to check if the process is terminated
5
+ #
6
+ # This class is not currently used in this Gem.
7
+ #
8
+ # @api public
9
+ #
10
+ class Process
11
+ # Spawns a new process using Process.spawn
12
+ #
13
+ # @example
14
+ # command = ['echo', 'hello world']
15
+ # options = { chdir: '/tmp' }
16
+ # process = ProcessExecuter::Process.new(*command, **options)
17
+ # process.pid # => 12345
18
+ # process.terminated? # => true
19
+ # process.status # => #<Process::Status: pid 12345 exit 0>
20
+ #
21
+ # @see https://ruby-doc.org/core/Process.html#method-c-spawn Process.spawn documentation
22
+ #
23
+ # @param command [Array] the command to execute
24
+ # @param spawn_options [Hash] the options to pass to Process.spawn
25
+ #
26
+ def initialize(*command, **spawn_options)
27
+ @pid = ::Process.spawn(*command, **spawn_options)
28
+ end
29
+
30
+ # @!attribute [r]
31
+ #
32
+ # The id of the process
33
+ #
34
+ # @example
35
+ # ProcessExecuter::Process.new('echo', 'hello world').pid # => 12345
36
+ #
37
+ # @return [Integer] The id of the process
38
+ #
39
+ attr_reader :pid
40
+
41
+ # @!attribute [r]
42
+ #
43
+ # The exit status of the process or `nil` if the process has not terminated
44
+ #
45
+ # @example
46
+ # ProcessExecuter::Process.new('echo', 'hello world').status # => #<Process::Status: pid 12345 exit 0>
47
+ #
48
+ # @return [::Process::Status, nil]
49
+ #
50
+ # The status is set only when `terminated?` is called and returns `true`.
51
+ #
52
+ attr_reader :status
53
+
54
+ # Return true if the process has terminated
55
+ #
56
+ # If the proces has terminated, `#status` is set to the exit status of the process.
57
+ #
58
+ # @example
59
+ # process = ProcessExecuter::Process.new('echo', 'hello world')
60
+ # sleep 1
61
+ # process.terminated? # => true
62
+ #
63
+ # @return [Boolean] true if the process has terminated
64
+ #
65
+ def terminated?
66
+ return true if @status
67
+
68
+ _pid, @status = ::Process.wait2(pid, ::Process::WNOHANG)
69
+ !@status.nil?
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProcessExecuter
4
+ # A replacement for Process::Status that can be used to mock the exit status of a process
5
+ #
6
+ # This class is not currently used in this Gem.
7
+ #
8
+ # Process::Status encapsulates the information on the status of a running or
9
+ # terminated system process. The built-in variable $? is either nil or a
10
+ # Process::Status object.
11
+ #
12
+ # ```ruby
13
+ # fork { exit 99 } #=> 26557
14
+ # Process.wait #=> 26557
15
+ # $?.class #=> Process::Status
16
+ # $?.to_i #=> 25344
17
+ # $? >> 8 #=> 99
18
+ # $?.stopped? #=> false
19
+ # $?.exited? #=> true
20
+ # $?.exitstatus #=> 99
21
+ # ```
22
+ #
23
+ # Posix systems record information on processes using a 16-bit integer. The
24
+ # lower bits record the process status (stopped, exited, signaled) and the
25
+ # upper bits possibly contain additional information (for example the program's
26
+ # return code in the case of exited processes). Pre Ruby 1.8, these bits were
27
+ # exposed directly to the Ruby program. Ruby now encapsulates these in a
28
+ # Process::Status object. To maximize compatibility, however, these objects
29
+ # retain a bit-oriented interface. In the descriptions that follow, when we
30
+ # talk about the integer value of stat, we're referring to this 16 bit value.
31
+ #
32
+ # @api public
33
+ #
34
+ class Status
35
+ # Create a new Status object
36
+ #
37
+ # @example
38
+ # status = ProcessExecuter::Status.new(999, 0)
39
+ # status.exited? # => true
40
+ # status.success? # => true
41
+ # status.exitstatus # => 0
42
+ #
43
+ def initialize(pid, stat)
44
+ @pid = pid
45
+ @stat = stat
46
+ end
47
+
48
+ # @!attribute
49
+ #
50
+ # The pid of the process
51
+ #
52
+ # @example
53
+ # status = ProcessExecuter::Status.new(999, 0)
54
+ # status.pid # => 999
55
+ #
56
+ # @return [Integer]
57
+ #
58
+ # @api public
59
+ #
60
+ attr_reader :pid
61
+
62
+ # @!attribute
63
+ #
64
+ # The status code of the process
65
+ #
66
+ # @example
67
+ # status = ProcessExecuter::Status.new(999, 123)
68
+ # status.stat # => 123
69
+ #
70
+ # @return [Integer]
71
+ #
72
+ # @api public
73
+ #
74
+ attr_reader :stat
75
+
76
+ # Logical AND of the bits in stat with `other`
77
+ #
78
+ # @example Process ended due to an uncaught signal 11 with a core dump
79
+ # status = ProcessExecuter::Status.new(999, 139)
80
+ # status & 127 # => 11 => the uncaught signal
81
+ # !(status & 128).zero? # => true => indicating a core dump
82
+ #
83
+ # @param other [Integer] the value to AND with stat
84
+ #
85
+ # @return [Integer] the result of the AND operation
86
+ #
87
+ def &(other)
88
+ stat & other
89
+ end
90
+
91
+ # Compare stat to `other`
92
+ #
93
+ # @example Process exited normally with exitstatus 99
94
+ # status = ProcessExecuter::Status.new(999, 25_344)
95
+ # status == 25_344 # => true
96
+ #
97
+ # @param other [Integer] the value to compare stat to
98
+ #
99
+ # @return [Boolean] true if stat == other, false otherwise
100
+ #
101
+ def ==(other)
102
+ stat == other
103
+ end
104
+
105
+ # rubocop:disable Naming/BinaryOperatorParameterName
106
+
107
+ # Shift the bits in stat right `num` places
108
+ #
109
+ # @example Process exited normally with exitstatus 99
110
+ # status = ProcessExecuter::Status.new(999, 25_344)
111
+ # status >> 8 # => 99
112
+ #
113
+ # @param num [Integer] the number of places to shift stat
114
+ #
115
+ # @return [Integer] the result of the shift operation
116
+ #
117
+ def >>(num)
118
+ stat >> num
119
+ end
120
+
121
+ # rubocop:enable Naming/BinaryOperatorParameterName
122
+
123
+ # Returns true if the process generated a coredump upon termination
124
+ #
125
+ # Not available on all platforms.
126
+ #
127
+ # @example process exited normally with exitstatus 99
128
+ # status = ProcessExecuter::Status.new(999, 25_344)
129
+ # status.coredump? # => false
130
+ #
131
+ # @example process ended due to an uncaught signal 11 with a core dump
132
+ # status = ProcessExecuter::Status.new(999, 139)
133
+ # status.coredump? # => true
134
+ #
135
+ # @return [Boolean] true if stat generated a coredump when it terminated
136
+ #
137
+ def coredump?
138
+ !(stat & 128).zero?
139
+ end
140
+
141
+ # Returns true if the process exited normally
142
+ #
143
+ # This happens when the process uses an exit() call or runs to the end of the program.
144
+ #
145
+ # @example process exited normally with exitstatus 0
146
+ # status = ProcessExecuter::Status.new(999, 0)
147
+ # status.exited? # => true
148
+ #
149
+ # @example process exited normally with exitstatus 99
150
+ # status = ProcessExecuter::Status.new(999, 25_344)
151
+ # status.exited? # => true
152
+ #
153
+ # @example process ended due to an uncaught signal 11 with a core dump
154
+ # status = ProcessExecuter::Status.new(999, 139)
155
+ # status.exited? # => false
156
+ #
157
+ # @return [Boolean] true if the process exited normally
158
+ #
159
+ def exited?
160
+ (stat & 127).zero?
161
+ end
162
+
163
+ # Returns the exit status of the process
164
+ #
165
+ # Returns nil if the process did not exit normally (when `#exited?` is false).
166
+ #
167
+ # @example process exited normally with exitstatus 99
168
+ # status = ProcessExecuter::Status.new(999, 25_344)
169
+ # status.exitstatus # => 99
170
+ #
171
+ # @return [Integer, nil] the exit status of the process
172
+ #
173
+ def exitstatus
174
+ stat >> 8 if exited?
175
+ end
176
+
177
+ # Returns true if the process was successful
178
+ #
179
+ # This means that `exited?` is true and `#exitstatus` is 0.
180
+ #
181
+ # Returns nil if the process did not exit normally (when `#exited?` is false).
182
+ #
183
+ # @example process exited normally with exitstatus 0
184
+ # status = ProcessExecuter::Status.new(999, 0)
185
+ # status.success? # => true
186
+ #
187
+ # @example process exited normally with exitstatus 99
188
+ # status = ProcessExecuter::Status.new(999, 25_344)
189
+ # status.success? # => false
190
+ #
191
+ # @example process ended due to an uncaught signal 11 with a core dump
192
+ # status = ProcessExecuter::Status.new(999, 139)
193
+ # status.success? # => nil
194
+ #
195
+ # @return [Boolean, nil] true if successful, false if unsuccessful, nil if the process did not exit normally
196
+ #
197
+ def success?
198
+ exitstatus.zero? if exited?
199
+ end
200
+
201
+ # Returns true if the process was stopped
202
+ #
203
+ # @example with a stopped process with signal 17
204
+ # status = ProcessExecuter::Status.new(999, 4_479)
205
+ # status.stopped? # => true
206
+ #
207
+ # @example process exited normally with exitstatus 99
208
+ # status = ProcessExecuter::Status.new(999, 25_344)
209
+ # status.stopped? # => false
210
+ #
211
+ # @example process ended due to an uncaught signal 11 with a core dump
212
+ # status = ProcessExecuter::Status.new(999, 139)
213
+ # status.stopped? # => false
214
+ #
215
+ # @return [Boolean] true if the process was stopped, false otherwise
216
+ #
217
+ def stopped?
218
+ (stat & 127) == 127
219
+ end
220
+
221
+ # The signal number that casused the process to stop
222
+ #
223
+ # Returns nil if the process is not stopped.
224
+ #
225
+ # @example with a stopped process with signal 17
226
+ # status = ProcessExecuter::Status.new(999, 4_479)
227
+ # status.stopsig # => 17
228
+ #
229
+ # @example process exited normally with exitstatus 99
230
+ # status = ProcessExecuter::Status.new(999, 25_344)
231
+ # status.stopsig # => nil
232
+ #
233
+ # @return [Integer, nil] the signal number that caused the process to stop or nil
234
+ #
235
+ def stopsig
236
+ stat >> 8 if stopped?
237
+ end
238
+
239
+ # Returns true if stat terminated because of an uncaught signal
240
+ #
241
+ # @example process ended due to an uncaught signal 9
242
+ # status = ProcessExecuter::Status.new(999, 9)
243
+ # status.signaled? # => true
244
+ #
245
+ # @example process exited normally with exitstatus 0
246
+ # status = ProcessExecuter::Status.new(999, 0)
247
+ # status.signaled? # => false
248
+ #
249
+ # @return [Boolean] true if stat terminated because of an uncaught signal, false otherwise
250
+ #
251
+ def signaled?
252
+ ![0, 127].include?(stat & 127)
253
+ end
254
+
255
+ # Returns the number of the signal that caused the process to terminate
256
+ #
257
+ # Returns nil if the process exited normally or is stopped.
258
+ #
259
+ # @example process ended due to an uncaught signal 9
260
+ # status = ProcessExecuter::Status.new(999, 9)
261
+ # status.termsig # => 9
262
+ #
263
+ # @example process exited normally with exitstatus 0
264
+ # status = ProcessExecuter::Status.new(999, 0)
265
+ # status.termsig # => nil
266
+ #
267
+ # @return [Integer, nil] the signal number that caused the process to terminate or nil
268
+ #
269
+ def termsig
270
+ stat & 127 if signaled?
271
+ end
272
+
273
+ # Returns the bits in stat as an Integer
274
+ #
275
+ # @example with a stopped process with signal 17
276
+ # status = ProcessExecuter::Status.new(999, 4_479)
277
+ # status.to_i # => 4_479
278
+ #
279
+ # @return [Integer] the bits in stat
280
+ #
281
+ def to_i
282
+ stat
283
+ end
284
+
285
+ # Show the status type, pid, and exit status as a string
286
+ #
287
+ # @example with a stopped process with signal 17
288
+ # status = ProcessExecuter::Status.new(999, 4_479)
289
+ # status.to_s # => "pid 999 stopped SIGSTOP (signal 17)"
290
+ #
291
+ # @return [String] the status type, pid, and exit status as a string
292
+ #
293
+ def to_s
294
+ type_to_s + (coredump? ? ' (core dumped)' : '')
295
+ end
296
+
297
+ # Show the status type, pid, and exit status as a string
298
+ #
299
+ # @example with a stopped process with signal 17
300
+ # status = ProcessExecuter::Status.new(999, 4_479)
301
+ # status.inspect # => "#<ProcessExecuter::Status pid 999 stopped SIGSTOP (signal 17)>"
302
+ #
303
+ # @return [String] the status type, pid, and exit status as a string
304
+ #
305
+ def inspect
306
+ "#<#{self.class} #{self}>"
307
+ end
308
+
309
+ private
310
+
311
+ # The string representation of a status based on how it was terminated
312
+ # @return [String] the string representation
313
+ # @api private
314
+ def type_to_s
315
+ if signaled?
316
+ signaled_to_s
317
+ elsif exited?
318
+ exited_to_s
319
+ elsif stopped?
320
+ stopped_to_s
321
+ end
322
+ end
323
+
324
+ # The string representation of a signaled process
325
+ # @return [String] the string representation of a signaled process
326
+ # @api private
327
+ def signaled_to_s
328
+ "pid #{pid} SIG#{Signal.signame(termsig)} (signal #{termsig})"
329
+ end
330
+
331
+ # The string representation of an exited process
332
+ # @return [String] the string representation of an exited process
333
+ # @api private
334
+ def exited_to_s
335
+ "pid #{pid} exit #{exitstatus}"
336
+ end
337
+
338
+ # The string representation of a stopped process
339
+ # @return [String] the string representation of a stopped process
340
+ # @api private
341
+ def stopped_to_s
342
+ "pid #{pid} stopped SIG#{Signal.signame(stopsig)} (signal #{stopsig})"
343
+ end
344
+ end
345
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class ProcessExecuter
4
- VERSION = '0.2.0'
3
+ module ProcessExecuter
4
+ VERSION = '0.3.0'
5
5
  end
@@ -1,225 +1,68 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Style/SingleLineMethods
3
+ require 'process_executer/monitored_pipe'
4
+ require 'process_executer/options'
5
+ require 'process_executer/process'
6
+ require 'process_executer/status'
4
7
 
5
- require 'process_executer/result'
6
- # require 'nio'
8
+ require 'timeout'
7
9
 
8
- # Execute a process and capture the output to a string, a file, and/or
9
- # pass the output through to this process's stdout/stderr.
10
+ # Execute a command in a subprocess and optionally capture its output
10
11
  #
11
12
  # @api public
12
13
  #
13
- class ProcessExecuter
14
- # stdout collected from the command
14
+ module ProcessExecuter
15
+ # Execute the specified command and return the exit status
15
16
  #
16
- # @example
17
- # command = ProcessExecuter.new
18
- # command.execute('echo hello')
19
- # command.out # => "hello\n"
20
- #
21
- # @return [String, nil] nil if collect_out? is false
22
- #
23
- attr_reader :out
24
-
25
- # stderr collected from the command
26
- #
27
- # @example
28
- # command = ProcessExecuter.new
29
- # command.execute('echo hello 1>&2')
30
- # command.err # => "hello\n"
31
- #
32
- # @return [String, nil] nil if collect_err? is false
33
- #
34
- attr_reader :err
35
-
36
- # The status of the command process
37
- #
38
- # Will be `nil` if the command has not completed execution.
39
- #
40
- # @example
41
- # command = ProcessExecuter.new
42
- # command.execute('echo hello')
43
- # command.status # => #<Process::Status: pid 86235 exit 0>
44
- #
45
- # @return [Process::Status, nil]
46
- #
47
- attr_reader :status
48
-
49
- # Show the command's stdout on this process's stdout
50
- #
51
- # @example
52
- # command = ProcessExecuter.new(passthru_out: true)
53
- # command.passthru_out? # => true
54
- #
55
- # @return [Boolean]
56
- #
57
- def passthru_out?; !!@passthru_out; end
58
-
59
- # Show the command's stderr on this process's stderr
60
- #
61
- # @example
62
- # command = ProcessExecuter.new(passthru_err: true)
63
- # command.passthru_err? # => true
64
- #
65
- # @return [Boolean]
66
- #
67
- def passthru_err?; !!@passthru_err; end
68
-
69
- # Collect the command's stdout the :out string (default is true)
17
+ # This method blocks until the command has terminated or the timeout has been reached.
70
18
  #
71
19
  # @example
72
- # command = ProcessExecuter.new(collect_out: false)
73
- # command.collect_out? # => false
74
- #
75
- # @return [Boolean]
76
- #
77
- def collect_out?; !!@collect_out; end
78
-
79
- # Collect the command's stderr the :err string (default is true)
20
+ # status = ProcessExecuter.spawn('echo hello')
21
+ # status.exited? # => true
22
+ # status.success? # => true
23
+ # stdout.string # => "hello\n"
80
24
  #
81
- # @example
82
- # command = ProcessExecuter.new(collect_err: false)
83
- # command.collect_err? # => false
25
+ # @example with a timeout
26
+ # status = ProcessExecuter.spawn('sleep 10', timeout: 0.01)
27
+ # status.exited? # => false
28
+ # status.success? # => nil
29
+ # status.signaled? # => true
30
+ # status.termsig # => 9
84
31
  #
85
- # @return [Boolean]
32
+ # @see https://ruby-doc.org/core-3.1.2/Kernel.html#method-i-spawn Kernel.spawn
33
+ # documentation for valid command and options
86
34
  #
87
- def collect_err?; !!@collect_err; end
88
-
89
- # Create a new ProcessExecuter
35
+ # @see ProcessExecuter::Options#initialize See ProcessExecuter::Options#initialize
36
+ # for additional options that may be specified
90
37
  #
91
- # @example
92
- # command = ProcessExecuter.new(passthru_out: false, passthru_err: false)
38
+ # @param command [Array<String>] the command to execute
39
+ # @param options_hash [Hash] the options to use for this execution context
93
40
  #
94
- # @param passthru_out [Boolean] show the command's stdout on this process's stdout
95
- # @param passthru_err [Boolean] show the command's stderr on this process's stderr
96
- # @param collect_out [Boolean] collect the command's stdout the :out string
97
- # @param collect_err [Boolean] collect the command's stderr the :err string
41
+ # @return [ProcessExecuter::ExecutionContext] the execution context that can run commands
98
42
  #
99
- def initialize(passthru_out: false, passthru_err: false, collect_out: true, collect_err: true)
100
- @passthru_out = passthru_out
101
- @passthru_err = passthru_err
102
- @collect_out = collect_out
103
- @collect_err = collect_err
43
+ def self.spawn(*command, **options_hash)
44
+ options = ProcessExecuter::Options.new(**options_hash)
45
+ pid = ::Process.spawn(*command, **options.spawn_options)
46
+ wait_for_process(pid, options)
104
47
  end
105
48
 
106
- # rubocop:disable Metrics/AbcSize
107
- # rubocop:disable Metrics/MethodLength
108
-
109
- # Execute the given command in a subprocess
49
+ # Wait for process to terminate
110
50
  #
111
- # See Process.spawn for acceptable values for command and options.
51
+ # If a timeout is speecified in options, kill the process after options.timeout seconds.
112
52
  #
113
- # Do no specify the following options: :in, :out, :err, integer, #fileno, :close_others.
53
+ # @param pid [Integer] the process id
54
+ # @param options [ProcessExecuter::Options] the options used
114
55
  #
115
- # @example Execute a command as a single string
116
- # result = ProcessExecuter.new.execute('echo hello')
56
+ # @return [ProcessExecuter::Status] the status of the process
117
57
  #
118
- # @example Execute a command as with each argument as a separate string
119
- # result = ProcessExecuter.new.execute('echo', 'hello')
120
- #
121
- # @example Execute a command in a specific directory
122
- # result = ProcessExecuter.new.execute('pwd', chdir: '/tmp')
123
- # result.out # => "/tmp\n"
124
- #
125
- # @example Execute a command with specific environment variables
126
- # result = ProcessExecuter.new.execute({ 'FOO' => 'bar' }, 'echo $FOO' )
127
- # result.out # => "bar\n"
128
- #
129
- # @param command [String, Array<String>] the command to pass to Process.spawn
130
- # @param options [Hash] options to pass to Process.spawn
131
- #
132
- # @return [ProcessExecuter::Result] the result of the command execution
133
- #
134
- def execute(*command, **options)
135
- @status = nil
136
- @out = (collect_out? ? '' : nil)
137
- @err = (collect_err? ? '' : nil)
138
-
139
- out_reader, out_writer = IO.pipe
140
- err_reader, err_writer = IO.pipe
141
-
142
- options[:out] = out_writer
143
- options[:err] = err_writer
144
-
145
- pid = Process.spawn(*command, options)
146
-
147
- loop do
148
- read_command_output(out_reader, err_reader)
149
-
150
- _pid, @status = Process.wait2(pid, Process::WNOHANG)
151
- break if @status
152
-
153
- # puts "finished_pid: #{finished_pid}"
154
- # puts "status: #{status}"
155
-
156
- # puts 'starting select'
157
- # readers, writers, exceptions = IO.select([stdout_reader, stderr_reader], nil, nil, 0.1)
158
- IO.select([out_reader, err_reader], nil, nil, 0.05)
159
-
160
- # puts "readers: #{readers}"
161
- # puts "writers: #{writers}"
162
- # puts "exceptions: #{exceptions}"
163
-
164
- # break unless readers || writers || exceptions
165
-
166
- _pid, @status = Process.wait2(pid, Process::WNOHANG)
167
- break if @status
168
-
169
- # puts "finished_pid: #{finished_pid}"
170
- # puts "status: #{status}"
171
- end
172
-
173
- out_writer.close
174
- err_writer.close
175
-
176
- # Read whatever is left over after the process terminates
177
- read_command_output(out_reader, err_reader)
178
- ProcessExecuter::Result.new(@status, @out, @err)
179
- end
180
-
181
- # rubocop:enable Metrics/AbcSize
182
- # rubocop:enable Metrics/MethodLength
183
-
184
- private
185
-
186
- # Read output from the given readers
187
- # @return [void]
188
58
  # @api private
189
- def read_command_output(out_reader, err_reader)
190
- loop do
191
- # Keep reading until there is nothing left to read
192
- break unless read_out(out_reader) || read_err(err_reader)
59
+ #
60
+ private_class_method def self.wait_for_process(pid, options)
61
+ Timeout.timeout(options.timeout) do
62
+ ::Process.wait2(pid).last
193
63
  end
194
- end
195
-
196
- # Read stdout from the given reader
197
- # @return [void]
198
- # @api private
199
- def read_out(reader)
200
- new_data = reader.read_nonblock(1024)
201
- # puts "new_stdout: '#{new_data}'"
202
- @out += new_data if new_data && collect_out?
203
- puts new_data if new_data && passthru_out?
204
- true
205
- rescue EOFError, IO::EAGAINWaitReadable
206
- # Nothing to read at this time
207
- false
208
- end
209
-
210
- # Read stderr from the given reader
211
- # @return [void]
212
- # @api private
213
- def read_err(reader)
214
- new_data = reader.read_nonblock(1024)
215
- # puts "new_stderr: '#{new_data}'"
216
- @err += new_data if new_data && collect_err?
217
- warn new_data if new_data && passthru_err?
218
- true
219
- rescue EOFError, IO::EAGAINWaitReadable
220
- # Nothing to read at this time
221
- false
64
+ rescue Timeout::Error
65
+ ::Process.kill('KILL', pid)
66
+ ::Process.wait2(pid).last
222
67
  end
223
68
  end
224
-
225
- # rubocop:enable Style/SingleLineMethods
@@ -41,6 +41,7 @@ Gem::Specification.new do |spec|
41
41
  spec.add_development_dependency 'rspec', '~> 3.10'
42
42
  spec.add_development_dependency 'rubocop', '~> 1.36'
43
43
  spec.add_development_dependency 'simplecov', '~> 0.21'
44
+ spec.add_development_dependency 'simplecov-lcov', '~> 0.8'
44
45
  spec.add_development_dependency 'solargraph', '~> 0.47'
45
46
  spec.add_development_dependency 'yard', '~> 0.9'
46
47
  spec.add_development_dependency 'yardstick', '~> 0.9'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: process_executer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Couball
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-11-16 00:00:00.000000000 Z
11
+ date: 2022-12-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bump
@@ -122,6 +122,20 @@ dependencies:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
124
  version: '0.21'
125
+ - !ruby/object:Gem::Dependency
126
+ name: simplecov-lcov
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.8'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.8'
125
139
  - !ruby/object:Gem::Dependency
126
140
  name: solargraph
127
141
  requirement: !ruby/object:Gem::Requirement
@@ -181,7 +195,10 @@ files:
181
195
  - README.md
182
196
  - Rakefile
183
197
  - lib/process_executer.rb
184
- - lib/process_executer/result.rb
198
+ - lib/process_executer/monitored_pipe.rb
199
+ - lib/process_executer/options.rb
200
+ - lib/process_executer/process.rb
201
+ - lib/process_executer/status.rb
185
202
  - lib/process_executer/version.rb
186
203
  - process_executer.gemspec
187
204
  homepage: https://github.com/main_branch/process_executer
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class ProcessExecuter
4
- # The result of executing a command
5
- #
6
- # @api public
7
- #
8
- class Result
9
- # The status of the command process
10
- #
11
- # @example
12
- # status # => #<Process::Status: pid 86235 exit 0>
13
- # out = 'hello'
14
- # err = 'ERROR'
15
- # command = ProcessExecuter.new(status, out, err)
16
- # command.status # => #<Process::Status: pid 86235 exit 0>
17
- #
18
- # @return [Process::Status]
19
- #
20
- attr_reader :status
21
-
22
- # The command's stdout (if collected)
23
- #
24
- # @example
25
- # status # => #<Process::Status: pid 86235 exit 0>
26
- # out = 'hello'
27
- # err = 'ERROR'
28
- # command = ProcessExecuter.new(status, out, err)
29
- # command.out # => "hello\n"
30
- #
31
- # @return [String, nil]
32
- #
33
- attr_reader :out
34
-
35
- # The command's stderr (if collected)
36
- #
37
- # @example
38
- # status # => #<Process::Status: pid 86235 exit 0>
39
- # out = 'hello'
40
- # err = 'ERROR'
41
- # command = ProcessExecuter.new(status, out, err)
42
- # command.out # => "ERROR\n"
43
- #
44
- # @return [String, nil]
45
- #
46
- attr_reader :err
47
-
48
- # Create a new Result object
49
- #
50
- # @example
51
- # status # => #<Process::Status: pid 86235 exit 0>
52
- # out = 'hello'
53
- # err = 'ERROR'
54
- # command = ProcessExecuter.new(status, out, err)
55
- #
56
- # @param status [Process::Status] the status of the command process
57
- # @param out [String, nil] the command's stdout (if collected)
58
- # @param err [String, nil] the command's stderr (if collected)
59
- #
60
- # @return [ProcessExecuter::Result] the result object
61
- #
62
- def initialize(status, out, err)
63
- @status = status
64
- @out = out
65
- @err = err
66
- end
67
- end
68
- end