process_executer 1.3.0 → 2.0.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: d2bd50be1f3683bc2c8210451c45e270e7072992ec48897a8bbb16cf10790c8b
4
- data.tar.gz: e0d1ba5a7c5571b0059db55537726b68baf6017a66941883953ece63f5a58b53
3
+ metadata.gz: 658921c991aa6654711a48a4638e0de6d998f9b5bcdabf8314cad15bb7294a0f
4
+ data.tar.gz: 7aee46a6fe1459cbd4e0bc9a67895a3c9946741b192e3766937b90ddad416c03
5
5
  SHA512:
6
- metadata.gz: bdaab34abbd99de650933f367eedcb40e7a2538d1f48ab8eda6f5df5da3d5f1748543eb28f54f21cc3557c64d34575001da2158caf2f30cc768bec88f657e16c
7
- data.tar.gz: 920227450b1da0c56aa3987a2ff1bbeb2c73cd093d04c1318ebb39d02302a408682d0d40c1668eb1f9b37d71f58fe47a6c4b101b75e701fdbaf3db2cdb845b26
6
+ metadata.gz: c25a01c7d819932a0495b18fc332744c860df5d25c8234df15071ac3ee66a9f6bc6b6e0101e2b02bc0fb691af3a7ac5bf9a77a98565b44964abcbc3d63f4451e
7
+ data.tar.gz: 915c369b55e999c726c22b2e3ef0b377ac0253afd115615d4cd2cb8ae17244c5f042bde4018bf31cb76ec30baf924761cb26e045577959b29e3f7aedcceddb65
data/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ All notable changes to the process_executer gem will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## v2.0.0 (2025-03-03)
9
+
10
+ [Full Changelog](https://github.com/main-branch/process_executer/compare/v1.3.0..v2.0.0)
11
+
12
+ Changes since v1.3.0:
13
+
14
+ * f0836cc feat: refactor the interface to simplify the gem
15
+
8
16
  ## v1.3.0 (2025-02-26)
9
17
 
10
18
  [Full Changelog](https://github.com/main-branch/process_executer/compare/v1.2.0..v1.3.0)
data/README.md CHANGED
@@ -11,16 +11,16 @@ Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?log
11
11
  [![Slack](https://img.shields.io/badge/slack-main--branch/process__executer-yellow.svg?logo=slack)](https://main-branch.slack.com/archives/C07NG2BPG8Y)
12
12
 
13
13
  * [Usage](#usage)
14
- * [ProcessExecuter.run](#processexecuterrun)
15
- * [ProcessExecuter::MonitoredPipe](#processexecutermonitoredpipe)
16
- * [ProcessExecuter.spawn](#processexecuterspawn)
14
+ * [ProcessExecuter.run](#processexecuterrun)
15
+ * [ProcessExecuter::MonitoredPipe](#processexecutermonitoredpipe)
16
+ * [ProcessExecuter.spawn\_and\_wait](#processexecuterspawn_and_wait)
17
17
  * [Installation](#installation)
18
18
  * [Contributing](#contributing)
19
- * [Reporting Issues](#reporting-issues)
20
- * [Developing](#developing)
21
- * [Commit message guidelines](#commit-message-guidelines)
22
- * [Pull request guidelines](#pull-request-guidelines)
23
- * [Releasing](#releasing)
19
+ * [Reporting Issues](#reporting-issues)
20
+ * [Developing](#developing)
21
+ * [Commit message guidelines](#commit-message-guidelines)
22
+ * [Pull request guidelines](#pull-request-guidelines)
23
+ * [Releasing](#releasing)
24
24
  * [License](#license)
25
25
 
26
26
  ## Usage
@@ -40,17 +40,17 @@ Supports the same features as
40
40
  [Process.spawn](https://docs.ruby-lang.org/en/3.3/Process.html#method-c-spawn).
41
41
  In addition, it (1) blocks until the command has exited, (2) captures stdout and
42
42
  stderr to a buffer or file, and (3) can optionally kill the command if it exceeds
43
- an timeout.
43
+ a given timeout duration.
44
44
 
45
45
  This command takes two forms:
46
46
 
47
47
  1. When passing a single string the command is passed to a shell:
48
48
 
49
- `ProcessExecuter.run([env, ] command_line, options = {}) ->` {ProcessExecuter::Command::Result}
49
+ `ProcessExecuter.run([env, ] command_line, options = {}) ->` {ProcessExecuter::Result}
50
50
 
51
51
  2. When passing an array of strings the command is run directly (bypassing the shell):
52
52
 
53
- `ProcessExecuter.run([env, ] exe_path, *args, options = {}) ->` {ProcessExecuter::Command::Result}
53
+ `ProcessExecuter.run([env, ] exe_path, *args, options = {}) ->` {ProcessExecuter::Result}
54
54
 
55
55
  Argument env, if given, is a hash that affects ENV for the new process; see
56
56
  [Execution
@@ -99,27 +99,27 @@ output_buffer.string #=> "Hello World\n"
99
99
  File.read('process.out') #=> "Hello World\n"
100
100
  ```
101
101
 
102
- Since the data is streamed, any object that implements `#write` can be used. For insance,
103
- you can use it to parse process output as a stream which might be useful for long XML
104
- or JSON output.
102
+ Since the data is streamed, any object that implements `#write` can be used. For
103
+ insance, you can use it to parse process output as a stream which might be useful for
104
+ long XML or JSON output.
105
105
 
106
- ### ProcessExecuter.spawn
106
+ ### ProcessExecuter.spawn_and_wait
107
107
 
108
108
  `ProcessExecuter.spawn` has the same interface as `Process.spawn` but has two
109
109
  important behaviorial differences:
110
110
 
111
111
  1. It blocks until the subprocess finishes
112
- 2. A timeout can be specified using the `:timeout` option
112
+ 2. A timeout can be specified using the `:timeout_after` option
113
113
 
114
- If the command does not terminate before the timeout, the process is killed by
115
- sending it the SIGKILL signal. The returned status object's `timeout?` attribute will
116
- return `true`. For example:
114
+ If the command does not terminate before the number of seconds specified by
115
+ `:timeout_after`, the process is killed by sending it the SIGKILL signal. The
116
+ returned Result object's `timed_out?` attribute will return `true`. For example:
117
117
 
118
118
  ```ruby
119
- status = ProcessExecuter.spawn('sleep 10', timeout: 0.01)
120
- status.signaled? #=> true
121
- status.termsig #=> 9
122
- status.timeout? #=> true
119
+ result = ProcessExecuter.spawn_and_wait('sleep 10', timeout_after: 0.01)
120
+ result.signaled? #=> true
121
+ result.termsig #=> 9
122
+ result.timed_out? #=> true
123
123
  ```
124
124
 
125
125
  ## Installation
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Layout/LineLength
4
+
5
+ module ProcessExecuter
6
+ # Base class for all ProcessExecuter::Command errors
7
+ #
8
+ # It is recommended to rescue `ProcessExecuter::Error` to catch any
9
+ # runtime error raised by this gem unless you need more specific error handling.
10
+ #
11
+ # Custom errors are arranged in the following class hierarchy:
12
+ #
13
+ # ```text
14
+ # ::StandardError
15
+ # └─> Error
16
+ # ├─> CommandError
17
+ # │ ├─> FailedError
18
+ # │ └─> SignaledError
19
+ # │ └─> TimeoutError
20
+ # └─> ProcessIOError
21
+ # ```
22
+ #
23
+ # | Error Class | Description |
24
+ # | --- | --- |
25
+ # | `Error` | This catch-all error serves as the base class for other custom errors. |
26
+ # | `CommandError` | A subclass of this error is raised when there is a problem executing a command. |
27
+ # | `FailedError` | Raised when the command exits with a non-zero status code. |
28
+ # | `SignaledError` | Raised when the command is terminated as a result of receiving a signal. This could happen if the process is forcibly terminated or if there is a serious system error. |
29
+ # | `TimeoutError` | This is a specific type of `SignaledError` that is raised when the command times out and is killed via the SIGKILL signal. Raised when the operation takes longer than the specified timeout duration (if provided). |
30
+ # | `ProcessIOError` | Raised when an error was encountered reading or writing to the command's subprocess. |
31
+ #
32
+ # @example Rescuing any error
33
+ # begin
34
+ # ProcessExecuter.run_command('git', 'status')
35
+ # rescue ProcessExecuter::Error => e
36
+ # puts "An error occurred: #{e.message}"
37
+ # end
38
+ #
39
+ # @example Rescuing a timeout error
40
+ # begin
41
+ # timeout_after = 0.1 # seconds
42
+ # ProcessExecuter.run_command('sleep', '1', timeout_after:)
43
+ # rescue ProcessExecuter::TimeoutError => e # Catch the more specific error first!
44
+ # puts "Command took too long and timed out: #{e}"
45
+ # rescue ProcessExecuter::Error => e
46
+ # puts "Some other error occured: #{e}"
47
+ # end
48
+ #
49
+ # @api public
50
+ #
51
+ class Error < ::StandardError; end
52
+
53
+ # Raised when a command fails or exits because of an uncaught signal
54
+ #
55
+ # The command executed, status, stdout, and stderr are available from this
56
+ # object.
57
+ #
58
+ # The Gem will raise a more specific error for each type of failure:
59
+ #
60
+ # * {FailedError}: when the command exits with a non-zero status
61
+ # * {SignaledError}: when the command exits because of an uncaught signal
62
+ # * {TimeoutError}: when the command times out
63
+ #
64
+ # @api public
65
+ #
66
+ class CommandError < ProcessExecuter::Error
67
+ # Create a CommandError object
68
+ #
69
+ # @example
70
+ # `exit 1` # set $? appropriately for this example
71
+ # result = ProcessExecuter::Result.new(%w[git status], $?, 'stdout', 'stderr')
72
+ # error = ProcessExecuter::CommandError.new(result)
73
+ # error.to_s #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"'
74
+ #
75
+ # @param result [Result] The result of the command including the command,
76
+ # status, stdout, and stderr
77
+ #
78
+ def initialize(result)
79
+ @result = result
80
+ super(error_message)
81
+ end
82
+
83
+ # The human readable representation of this error
84
+ #
85
+ # @example
86
+ # error.error_message #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"'
87
+ #
88
+ # @return [String]
89
+ #
90
+ def error_message
91
+ "#{result.command}, status: #{result}, stderr: #{result.stderr.inspect}"
92
+ end
93
+
94
+ # @attribute [r] result
95
+ #
96
+ # The result of the command including the command, its status and its output
97
+ #
98
+ # @example
99
+ # error.result #=> #<ProcessExecuter::Result:0x00007f9b1b8b3d20>
100
+ #
101
+ # @return [Result]
102
+ #
103
+ attr_reader :result
104
+ end
105
+
106
+ # Raised when the command returns a non-zero exitstatus
107
+ #
108
+ # @api public
109
+ #
110
+ class FailedError < ProcessExecuter::CommandError; end
111
+
112
+ # Raised when the command exits because of an uncaught signal
113
+ #
114
+ # @api public
115
+ #
116
+ class SignaledError < ProcessExecuter::CommandError; end
117
+
118
+ # Raised when the command takes longer than the configured timeout_after
119
+ #
120
+ # @example
121
+ # result.timed_out? #=> true
122
+ #
123
+ # @api public
124
+ #
125
+ class TimeoutError < ProcessExecuter::SignaledError; end
126
+
127
+ # Raised when the output of a command can not be read
128
+ #
129
+ # @api public
130
+ #
131
+ class ProcessIOError < ProcessExecuter::Error; end
132
+ end
133
+
134
+ # rubocop:enable Layout/LineLength
@@ -7,7 +7,7 @@ module ProcessExecuter
7
7
  #
8
8
  # Valid options are those accepted by Process.spawn plus the following additions:
9
9
  #
10
- # * `:timeout`:
10
+ # * `:timeout_after`: the number of seconds to allow a process to run before killing it
11
11
  #
12
12
  # @api public
13
13
  #
@@ -30,7 +30,7 @@ module ProcessExecuter
30
30
  # to `Process.spawn`
31
31
  #
32
32
  NON_SPAWN_OPTIONS = %i[
33
- timeout
33
+ timeout_after raise_errors
34
34
  ].freeze
35
35
 
36
36
  # Any `SPAWN_OPTIONS` set to `NOT_SET` will not be passed to `Process.spawn`
@@ -50,7 +50,8 @@ module ProcessExecuter
50
50
  umask: NOT_SET,
51
51
  close_others: NOT_SET,
52
52
  chdir: NOT_SET,
53
- timeout: nil
53
+ raise_errors: true,
54
+ timeout_after: nil
54
55
  }.freeze
55
56
 
56
57
  # :nocov:
@@ -71,14 +72,14 @@ module ProcessExecuter
71
72
  # Create a new Options object
72
73
  #
73
74
  # @example
74
- # options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout: 10)
75
+ # options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout_after: 10)
75
76
  #
76
77
  # @param options [Hash] Process.spawn options plus additional options listed below.
77
78
  #
78
79
  # See [Process.spawn](https://ruby-doc.org/core/Process.html#method-c-spawn)
79
80
  # for a list of valid options that can be passed to `Process.spawn`.
80
81
  #
81
- # @option options [Integer, Float, nil] :timeout
82
+ # @option options [Integer, Float, nil] :timeout_after
82
83
  # Number of seconds to wait for the process to terminate. Any number
83
84
  # may be used, including Floats to specify fractional seconds. A value of 0 or nil
84
85
  # will allow the process to run indefinitely.
@@ -92,7 +93,7 @@ module ProcessExecuter
92
93
  # Returns the options to be passed to Process.spawn
93
94
  #
94
95
  # @example
95
- # options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout: 10)
96
+ # options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout_after: 10)
96
97
  # options.spawn_options # => { out: $stdout, err: $stderr }
97
98
  #
98
99
  # @return [Hash]
@@ -130,22 +131,24 @@ module ProcessExecuter
130
131
  raise ArgumentError, "Unknown options: #{unknown_options.join(', ')}" unless unknown_options.empty?
131
132
  end
132
133
 
133
- # Raise an error if timeout is not a real non-negative number
134
+ # Raise an error if timeout_after is not a non-negative real number
134
135
  # @return [void]
135
- # @raise [ArgumentError] if timeout is not a real non-negative number
136
+ # @raise [ArgumentError] if timeout_after is not a non-negative real number
136
137
  # @api private
137
138
  def assert_timeout_is_valid
138
- return if @options[:timeout].nil?
139
- return if @options[:timeout].is_a?(Numeric) && @options[:timeout].real? && !@options[:timeout].negative?
139
+ return if @options[:timeout_after].nil?
140
+ return if @options[:timeout_after].is_a?(Numeric) &&
141
+ @options[:timeout_after].real? &&
142
+ !@options[:timeout_after].negative?
140
143
 
141
- raise ArgumentError, invalid_timeout_message
144
+ raise ArgumentError, invalid_timeout_after_message
142
145
  end
143
146
 
144
- # The message to be used when raising an error for an invalid timeout
147
+ # The message to be used when raising an error for an invalid timeout_after
145
148
  # @return [String]
146
149
  # @api private
147
- def invalid_timeout_message
148
- "timeout must be nil or a real non-negative number but was #{options[:timeout].pretty_inspect}"
150
+ def invalid_timeout_after_message
151
+ "timeout_after must be nil or a non-negative real number but was #{options[:timeout_after].pretty_inspect}"
149
152
  end
150
153
 
151
154
  # Determine if the given option is a valid option
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ module ProcessExecuter
6
+ # A decorator for Process::Status that adds the following attributes:
7
+ #
8
+ # * `command`: the command that was used to spawn the process
9
+ # * `options`: the options that were used to spawn the process
10
+ # * `elapsed_time`: the secs the command ran
11
+ # * `stdout`: the captured stdout output
12
+ # * `stderr`: the captured stderr output
13
+ # * `timed_out?`: true if the process timed out
14
+ #
15
+ # @api public
16
+ #
17
+ class Result < SimpleDelegator
18
+ # Create a new Result object
19
+ #
20
+ # @param status [Process::Status] the status to delegate to
21
+ # @param command [Array] the command that was used to spawn the process
22
+ # @param options [ProcessExecuter::Options] the options that were used to spawn the process
23
+ # @param timed_out [Boolean] true if the process timed out
24
+ # @param elapsed_time [Numeric] the secs the command ran
25
+ #
26
+ # @example
27
+ # command = ['sleep 1']
28
+ # options = ProcessExecuter::Options.new(timeout_after: 0.5)
29
+ # start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
30
+ # timed_out = false
31
+ # status = nil
32
+ # pid = Process.spawn(*command, **options.spawn_options)
33
+ # Timeout.timeout(options.timeout_after) do
34
+ # _pid, status = Process.wait2(pid)
35
+ # rescue Timeout::Error
36
+ # Process.kill('KILL', pid)
37
+ # timed_out = true
38
+ # _pid, status = Process.wait2(pid)
39
+ # end
40
+ # elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
41
+ #
42
+ # ProcessExecuter::Result.new(status, command:, options:, timed_out:, elapsed_time:)
43
+ #
44
+ # @api public
45
+ #
46
+ def initialize(status, command:, options:, timed_out:, elapsed_time:)
47
+ super(status)
48
+ @command = command
49
+ @options = options
50
+ @timed_out = timed_out
51
+ @elapsed_time = elapsed_time
52
+ end
53
+
54
+ # The command that was used to spawn the process
55
+ # @see Process.spawn
56
+ # @example
57
+ # result.command #=> [{ 'GIT_DIR' => '/path/to/repo' }, 'git', 'status']
58
+ # @return [Array]
59
+ attr_reader :command
60
+
61
+ # The options that were used to spawn the process
62
+ # @see Process.spawn
63
+ # @example
64
+ # result.options #=> { chdir: '/path/to/repo', timeout_after: 0.5 }
65
+ # @return [Hash]
66
+ # @api public
67
+ attr_reader :options
68
+
69
+ # The secs the command ran
70
+ # @example
71
+ # result.elapsed_time #=> 10
72
+ # @return [Numeric, nil]
73
+ # @api public
74
+ attr_reader :elapsed_time
75
+
76
+ # @!attribute [r] timed_out?
77
+ # True if the process timed out and was sent the SIGKILL signal
78
+ # @example
79
+ # result = ProcessExecuter.spawn('sleep 10', timeout_after: 0.01)
80
+ # result.timed_out? # => true
81
+ # @return [Boolean]
82
+ #
83
+ def timed_out? = @timed_out
84
+
85
+ # Overrides the default success? method to return nil if the process timed out
86
+ #
87
+ # This is because when a timeout occurs, Windows will still return true.
88
+ #
89
+ # @example
90
+ # result = ProcessExecuter.spawn('sleep 10', timeout_after: 0.01)
91
+ # result.success? # => nil
92
+ # @return [true, nil]
93
+ #
94
+ def success?
95
+ return nil if timed_out? # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
96
+
97
+ super
98
+ end
99
+
100
+ # Return a string representation of the result
101
+ # @example
102
+ # result.to_s #=> "pid 70144 SIGKILL (signal 9) timed out after 10s"
103
+ # @return [String]
104
+ def to_s
105
+ "#{super}#{timed_out? ? " timed out after #{options.timeout_after}s" : ''}"
106
+ end
107
+
108
+ # Return the captured stdout output
109
+ #
110
+ # This output is only returned if the `:out` option was set to a
111
+ # `ProcessExecuter::MonitoredPipe` that includes a writer that implements `#string`
112
+ # method (e.g. a StringIO).
113
+ #
114
+ # @example
115
+ # # Note that `ProcessExecuter.run` will wrap the given out: object in a
116
+ # # ProcessExecuter::MonitoredPipe
117
+ # result = ProcessExecuter.run('echo hello': out: StringIO.new)
118
+ # result.stdout #=> "hello\n"
119
+ #
120
+ # @return [String, nil]
121
+ #
122
+ def stdout
123
+ Array(options.out).each do |pipe|
124
+ next unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
125
+
126
+ pipe.writers.each do |writer|
127
+ return writer.string if writer.respond_to?(:string)
128
+ end
129
+ end
130
+
131
+ nil
132
+ end
133
+
134
+ # Return the captured stderr output
135
+ #
136
+ # This output is only returned if the `:err` option was set to a
137
+ # `ProcessExecuter::MonitoredPipe` that includes a writer that implements `#string`
138
+ # method (e.g. a StringIO).
139
+ #
140
+ # @example
141
+ # # Note that `ProcessExecuter.run` will wrap the given err: object in a
142
+ # # ProcessExecuter::MonitoredPipe
143
+ # result = ProcessExecuter.run('echo ERROR 1>&2', err: StringIO.new)
144
+ # resuilt.stderr #=> "ERROR\n"
145
+ #
146
+ # @return [String, nil]
147
+ #
148
+ def stderr
149
+ Array(options.err).each do |pipe|
150
+ next unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
151
+
152
+ pipe.writers.each do |writer|
153
+ return writer.string if writer.respond_to?(:string)
154
+ end
155
+ end
156
+
157
+ nil
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module ProcessExecuter
6
+ # The `Runner` class executes subprocess commands and captures their status and output.
7
+ #
8
+ # It does the following:
9
+ # - Run commands (`call`) with options for capturing output, handling timeouts, and merging stdout/stderr.
10
+ # - Process command results, including logging and error handling.
11
+ # - Raise detailed exceptions for common command failures, such as timeouts or subprocess errors.
12
+ #
13
+ # This class is used internally by {ProcessExecuter.run}.
14
+ #
15
+ # @api public
16
+ #
17
+ class Runner
18
+ # Create a new RunCommand instance
19
+ #
20
+ # @example
21
+ # runner = Runner.new()
22
+ # result = runner.call('echo', 'hello')
23
+ #
24
+ # @param logger [Logger] The logger to use. Defaults to a no-op logger if nil.
25
+ #
26
+ def initialize(logger = Logger.new(nil))
27
+ @logger = logger
28
+ end
29
+
30
+ # The logger to use
31
+ # @example
32
+ # runner.logger #=> #<Logger:0x00007f9b1b8b3d20>
33
+ # @return [Logger]
34
+ attr_reader :logger
35
+
36
+ # Run a command and return the status including stdout and stderr output
37
+ #
38
+ # @example
39
+ # runner = ProcessExecuter::Runner.new()
40
+ # result = runner.call('echo hello')
41
+ # result = ProcessExecuter.run('echo hello')
42
+ # result.success? # => true
43
+ # result.exitstatus # => 0
44
+ # result.stdout # => "hello\n"
45
+ # result.stderr # => ""
46
+ #
47
+ # @param command [Array<String>] The command to run
48
+ # @param out [#write, Array<#write>, nil] The object (or array of objects) to which stdout is written
49
+ # @param err [#write, Array<#write>, nil] The object (or array of objects) to which stderr is written
50
+ # @param merge [Boolean] Write both stdout and stderr into the buffer for stdout
51
+ # @param options_hash [Hash] Additional options to pass to Process.spawn
52
+ #
53
+ # See {ProcessExecuter.run} for a full list of options.
54
+ #
55
+ # @return [ProcessExecuter::Result] The result of the completed subprocess
56
+ #
57
+ def call(*command, out: nil, err: nil, merge: false, **options_hash)
58
+ out ||= StringIO.new
59
+ err ||= (merge ? out : StringIO.new)
60
+
61
+ spawn(command, out:, err:, **options_hash).tap { |result| process_result(result) }
62
+ end
63
+
64
+ private
65
+
66
+ # Wrap the output buffers in pipes and then execute the command
67
+ #
68
+ # @param command [Array<String>] The command to execute
69
+ # @param out [#write, Array<#write>] The object (or array of objects) to which stdout is written
70
+ # @param err [#write, Array<#write>] The object (or array of objects) to which stderr is written
71
+ # @param options_hash [Hash] Additional options to pass to Process.spawn
72
+ #
73
+ # See {ProcessExecuter.run} for a full list of options.
74
+ #
75
+ # @raise [ProcessExecuter::ProcessIOError] If an exception was raised while collecting subprocess output
76
+ # @raise [ProcessExecuter::TimeoutError] If the command times out
77
+ #
78
+ # @return [ProcessExecuter::Result] The result of the completed subprocess
79
+ #
80
+ # @api private
81
+ #
82
+ def spawn(command, out:, err:, **options_hash)
83
+ out = [out] unless out.is_a?(Array)
84
+ err = [err] unless err.is_a?(Array)
85
+ out_pipe = ProcessExecuter::MonitoredPipe.new(*out)
86
+ err_pipe = ProcessExecuter::MonitoredPipe.new(*err)
87
+ ProcessExecuter.spawn_and_wait(*command, out: out_pipe, err: err_pipe, **options_hash)
88
+ ensure
89
+ out_pipe.close
90
+ err_pipe.close
91
+ raise_pipe_error(command, :stdout, out_pipe) if out_pipe.exception
92
+ raise_pipe_error(command, :stderr, err_pipe) if err_pipe.exception
93
+ end
94
+
95
+ # Process the result of the command and return a ProcessExecuter::Result
96
+ #
97
+ # Log the command and result, and raise an error if the command failed.
98
+ #
99
+ # @param result [ProcessExecuter::Result] The result of the command
100
+ #
101
+ # @return [Void]
102
+ #
103
+ # @raise [ProcessExecuter::FailedError] If the command failed
104
+ # @raise [ProcessExecuter::SignaledError] If the command was signaled
105
+ # @raise [ProcessExecuter::TimeoutError] If the command times out
106
+ # @raise [ProcessExecuter::ProcessIOError] If an exception was raised while collecting subprocess output
107
+ #
108
+ # @api private
109
+ #
110
+ def process_result(result)
111
+ log_result(result)
112
+
113
+ return unless result.options.raise_errors
114
+
115
+ raise TimeoutError, result if result.timed_out?
116
+ raise SignaledError, result if result.signaled?
117
+ raise FailedError, result unless result.success?
118
+ end
119
+
120
+ # Log the result of running the command
121
+ # @param result [ProcessExecuter::Result] the result of the command including
122
+ # the command, status, stdout, and stderr
123
+ # @return [void]
124
+ # @api private
125
+ def log_result(result)
126
+ logger.info { "#{result.command} exited with status #{result}" }
127
+ logger.debug { "stdout:\n#{result.stdout.inspect}\nstderr:\n#{result.stderr.inspect}" }
128
+ end
129
+
130
+ # Raise an error when there was exception while collecting the subprocess output
131
+ #
132
+ # @param command [Array<String>] The command that was executed
133
+ # @param pipe_name [Symbol] The name of the pipe that raised the exception
134
+ # @param pipe [ProcessExecuter::MonitoredPipe] The pipe that raised the exception
135
+ #
136
+ # @raise [ProcessExecuter::ProcessIOError]
137
+ #
138
+ # @return [void] This method always raises an error
139
+ #
140
+ # @api private
141
+ #
142
+ def raise_pipe_error(command, pipe_name, pipe)
143
+ error = ProcessExecuter::ProcessIOError.new("Pipe Exception for #{command}: #{pipe_name}")
144
+ raise(error, cause: pipe.exception)
145
+ end
146
+ end
147
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ProcessExecuter
4
4
  # The current Gem version
5
- VERSION = '1.3.0'
5
+ VERSION = '2.0.0'
6
6
  end