process_executer 1.1.2 → 1.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 +4 -4
- data/.commitlintrc.yml +16 -0
- data/.husky/commit-msg +1 -0
- data/.rubocop.yml +6 -22
- data/.tool-versions +1 -1
- data/.yardopts +1 -1
- data/CHANGELOG.md +72 -16
- data/{LICENSE.md → LICENSE.txt} +1 -1
- data/README.md +54 -44
- data/Rakefile +13 -13
- data/lib/process_executer/command/errors.rb +170 -0
- data/lib/process_executer/command/result.rb +77 -0
- data/lib/process_executer/command/runner.rb +167 -0
- data/lib/process_executer/command.rb +12 -0
- data/lib/process_executer/monitored_pipe.rb +46 -9
- data/lib/process_executer/status.rb +31 -6
- data/lib/process_executer/version.rb +1 -1
- data/lib/process_executer.rb +217 -13
- data/package.json +11 -0
- data/process_executer.gemspec +15 -8
- metadata +52 -15
@@ -0,0 +1,167 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'errors'
|
4
|
+
require_relative 'result'
|
5
|
+
|
6
|
+
module ProcessExecuter
|
7
|
+
module Command
|
8
|
+
# The `Runner` class executes subprocess commands and captures their status and output.
|
9
|
+
#
|
10
|
+
# It does the following:
|
11
|
+
# - Run commands (`call`) with options for capturing output, handling timeouts, and merging stdout/stderr.
|
12
|
+
# - Process command results, including logging and error handling.
|
13
|
+
# - Raise detailed exceptions for common command failures, such as timeouts or subprocess errors.
|
14
|
+
#
|
15
|
+
# This class is used internally by {ProcessExecuter.run}.
|
16
|
+
#
|
17
|
+
# @api public
|
18
|
+
#
|
19
|
+
class Runner
|
20
|
+
# Create a new RunCommand instance
|
21
|
+
#
|
22
|
+
# @example
|
23
|
+
# runner = Runner.new()
|
24
|
+
# status = runner.call('echo', 'hello')
|
25
|
+
#
|
26
|
+
# @param logger [Logger] The logger to use. Defaults to a no-op logger if nil.
|
27
|
+
#
|
28
|
+
def initialize(logger)
|
29
|
+
@logger = logger || Logger.new(nil)
|
30
|
+
end
|
31
|
+
|
32
|
+
# The logger to use
|
33
|
+
# @example
|
34
|
+
# runner.logger #=> #<Logger:0x00007f9b1b8b3d20>
|
35
|
+
# @return [Logger]
|
36
|
+
attr_reader :logger
|
37
|
+
|
38
|
+
# rubocop:disable Metrics/ParameterLists
|
39
|
+
|
40
|
+
# Run a command and return the status including stdout and stderr output
|
41
|
+
#
|
42
|
+
# @example
|
43
|
+
# command = %w[git status]
|
44
|
+
# status = run(command)
|
45
|
+
# status.success? # => true
|
46
|
+
# status.exitstatus # => 0
|
47
|
+
# status.out # => "On branch master\nnothing to commit, working tree clean\n"
|
48
|
+
# status.err # => ""
|
49
|
+
#
|
50
|
+
# @param command [Array<String>] The command to run
|
51
|
+
# @param out [#write] The object to which stdout is written
|
52
|
+
# @param err [#write] The object to which stderr is written
|
53
|
+
# @param merge [Boolean] Write both stdout and stderr into the buffer for stdout
|
54
|
+
# @param raise_errors [Boolean] Raise an exception if the command fails
|
55
|
+
# @param options_hash [Hash] Additional options to pass to Process.spawn
|
56
|
+
#
|
57
|
+
# See {ProcessExecuter.run} for a full list of options.
|
58
|
+
#
|
59
|
+
# @return [ProcessExecuter::Command::Result] The status of the subprocess and captured stdout and stderr output
|
60
|
+
#
|
61
|
+
def call(*command, out: nil, err: nil, merge: false, raise_errors: true, **options_hash)
|
62
|
+
out ||= StringIO.new
|
63
|
+
err ||= (merge ? out : StringIO.new)
|
64
|
+
|
65
|
+
status = spawn(command, out:, err:, **options_hash)
|
66
|
+
|
67
|
+
process_result(command, status, out, err, options_hash[:timeout], raise_errors)
|
68
|
+
end
|
69
|
+
|
70
|
+
# rubocop:enable Metrics/ParameterLists
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
# Wrap the output buffers in pipes and then execute the command
|
75
|
+
#
|
76
|
+
# @param command [Array<String>] The command to execute
|
77
|
+
# @param out [#write] The object to which stdout is written
|
78
|
+
# @param err [#write] The object to which stderr is written
|
79
|
+
# @param options_hash [Hash] Additional options to pass to Process.spawn
|
80
|
+
#
|
81
|
+
# See {ProcessExecuter.run} for a full list of options.
|
82
|
+
#
|
83
|
+
# @raise [ProcessExecuter::Command::ProcessIOError] If an exception was raised while collecting subprocess output
|
84
|
+
# @raise [ProcessExecuter::Command::TimeoutError] If the command times out
|
85
|
+
#
|
86
|
+
# @return [ProcessExecuter::Status] The status of the completed subprocess
|
87
|
+
#
|
88
|
+
# @api private
|
89
|
+
#
|
90
|
+
def spawn(command, out:, err:, **options_hash)
|
91
|
+
out = [out] unless out.is_a?(Array)
|
92
|
+
err = [err] unless err.is_a?(Array)
|
93
|
+
out_pipe = ProcessExecuter::MonitoredPipe.new(*out)
|
94
|
+
err_pipe = ProcessExecuter::MonitoredPipe.new(*err)
|
95
|
+
ProcessExecuter.spawn(*command, out: out_pipe, err: err_pipe, **options_hash)
|
96
|
+
ensure
|
97
|
+
out_pipe.close
|
98
|
+
err_pipe.close
|
99
|
+
raise_pipe_error(command, :stdout, out_pipe) if out_pipe.exception
|
100
|
+
raise_pipe_error(command, :stderr, err_pipe) if err_pipe.exception
|
101
|
+
end
|
102
|
+
|
103
|
+
# rubocop:disable Metrics/ParameterLists
|
104
|
+
|
105
|
+
# Process the result of the command and return a ProcessExecuter::Command::Result
|
106
|
+
#
|
107
|
+
# Log the command and result, and raise an error if the command failed.
|
108
|
+
#
|
109
|
+
# @param command [Array<String>] The git command that was executed
|
110
|
+
# @param status [Process::Status] The status of the completed subprocess
|
111
|
+
# @param out [#write] The object that stdout was written to
|
112
|
+
# @param err [#write] The object that stderr was written to
|
113
|
+
# @param timeout [Numeric, nil] The maximum seconds to wait for the command to complete
|
114
|
+
# @param raise_errors [Boolean] Raise an exception if the command fails
|
115
|
+
#
|
116
|
+
# @return [ProcessExecuter::Command::Result] The status of the subprocess and captured stdout and stderr output
|
117
|
+
#
|
118
|
+
# @raise [ProcessExecuter::Command::FailedError] If the command failed
|
119
|
+
# @raise [ProcessExecuter::Command::SignaledError] If the command was signaled
|
120
|
+
# @raise [ProcessExecuter::Command::TimeoutError] If the command times out
|
121
|
+
# @raise [ProcessExecuter::Command::ProcessIOError] If an exception was raised while collecting subprocess output
|
122
|
+
#
|
123
|
+
# @api private
|
124
|
+
#
|
125
|
+
def process_result(command, status, out, err, timeout, raise_errors)
|
126
|
+
Result.new(command, status, out, err).tap do |result|
|
127
|
+
log_result(result)
|
128
|
+
|
129
|
+
if raise_errors
|
130
|
+
raise TimeoutError.new(result, timeout) if status.timeout?
|
131
|
+
raise SignaledError, result if status.signaled?
|
132
|
+
raise FailedError, result unless status.success?
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# rubocop:enable Metrics/ParameterLists
|
138
|
+
|
139
|
+
# Log the command and result of the subprocess
|
140
|
+
# @param result [ProcessExecuter::Command::Result] the result of the command including
|
141
|
+
# the command, status, stdout, and stderr
|
142
|
+
# @return [void]
|
143
|
+
# @api private
|
144
|
+
def log_result(result)
|
145
|
+
logger.info { "#{result.command} exited with status #{result}" }
|
146
|
+
logger.debug { "stdout:\n#{result.stdout_to_s.inspect}\nstderr:\n#{result.stderr_to_s.inspect}" }
|
147
|
+
end
|
148
|
+
|
149
|
+
# Raise an error when there was exception while collecting the subprocess output
|
150
|
+
#
|
151
|
+
# @param command [Array<String>] The command that was executed
|
152
|
+
# @param pipe_name [Symbol] The name of the pipe that raised the exception
|
153
|
+
# @param pipe [ProcessExecuter::MonitoredPipe] The pipe that raised the exception
|
154
|
+
#
|
155
|
+
# @raise [ProcessExecuter::Command::ProcessIOError]
|
156
|
+
#
|
157
|
+
# @return [void] This method always raises an error
|
158
|
+
#
|
159
|
+
# @api private
|
160
|
+
#
|
161
|
+
def raise_pipe_error(command, pipe_name, pipe)
|
162
|
+
error = ProcessExecuter::Command::ProcessIOError.new("Pipe Exception for #{command}: #{pipe_name}")
|
163
|
+
raise(error, cause: pipe.exception)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ProcessExecuter
|
4
|
+
# This module contains classes for implementing ProcessExecuter.run_command
|
5
|
+
module Command; end
|
6
|
+
end
|
7
|
+
|
8
|
+
require_relative 'command/errors'
|
9
|
+
require_relative 'command/result'
|
10
|
+
require_relative 'command/runner'
|
11
|
+
|
12
|
+
# Runs a command and returns the result
|
@@ -281,19 +281,56 @@ module ProcessExecuter
|
|
281
281
|
# @api private
|
282
282
|
def monitor_pipe
|
283
283
|
new_data = pipe_reader.read_nonblock(chunk_size)
|
284
|
-
|
285
|
-
# :nocov:
|
286
|
-
begin
|
287
|
-
# :nocov:
|
288
|
-
writers.each { |w| w.write(new_data) }
|
289
|
-
rescue StandardError => e
|
290
|
-
@exception = e
|
291
|
-
@state = :closing
|
292
|
-
end
|
284
|
+
write_data(new_data)
|
293
285
|
rescue IO::WaitReadable
|
294
286
|
pipe_reader.wait_readable(0.001)
|
295
287
|
end
|
296
288
|
|
289
|
+
# Check if the writer is a file descriptor
|
290
|
+
#
|
291
|
+
# @param writer [#write] the writer to check
|
292
|
+
# @return [Boolean] true if the writer is a file descriptor
|
293
|
+
# @api private
|
294
|
+
def file_descriptor?(writer) = writer.is_a?(Integer) || writer.is_a?(Symbol)
|
295
|
+
|
296
|
+
# Write the data read from the pipe to all destinations
|
297
|
+
#
|
298
|
+
# If an exception is raised by a writer, set the state to `:closing`
|
299
|
+
# so that the pipe can be closed.
|
300
|
+
#
|
301
|
+
# @param data [String] the data read from the pipe
|
302
|
+
# @return [void]
|
303
|
+
# @api private
|
304
|
+
def write_data(data)
|
305
|
+
writers.each do |w|
|
306
|
+
file_descriptor?(w) ? write_data_to_fd(w, data) : w.write(data)
|
307
|
+
end
|
308
|
+
rescue StandardError => e
|
309
|
+
@exception = e
|
310
|
+
@state = :closing
|
311
|
+
end
|
312
|
+
|
313
|
+
# Write data to the given file_descriptor correctly handling stdout and stderr
|
314
|
+
# @param file_descriptor [Integer, Symbol] the file descriptor to write to (either an integer or :out or :err)
|
315
|
+
# @param data [String] the data to write
|
316
|
+
# @return [void]
|
317
|
+
# @api private
|
318
|
+
def write_data_to_fd(file_descriptor, data)
|
319
|
+
# The case line is not marked as not covered only when using TruffleRuby
|
320
|
+
# :nocov:
|
321
|
+
case file_descriptor
|
322
|
+
# :nocov:
|
323
|
+
when :out, 1
|
324
|
+
$stdout.write data
|
325
|
+
when :err, 2
|
326
|
+
$stderr.write data
|
327
|
+
else
|
328
|
+
io = IO.open(file_descriptor, mode: 'a', autoclose: false)
|
329
|
+
io.write(data)
|
330
|
+
io.close
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
297
334
|
# Read any remaining data from the pipe and close it
|
298
335
|
#
|
299
336
|
# @return [void]
|
@@ -15,6 +15,7 @@ module ProcessExecuter
|
|
15
15
|
#
|
16
16
|
# @param status [Process::Status] the status to delegate to
|
17
17
|
# @param timeout [Boolean] true if the process timed out
|
18
|
+
# @param timeout_duration [Numeric, nil] The secs the command ran before being killed OR o or nil for no timeout
|
18
19
|
#
|
19
20
|
# @example
|
20
21
|
# status = Process.wait2(pid).last
|
@@ -23,23 +24,47 @@ module ProcessExecuter
|
|
23
24
|
#
|
24
25
|
# @api public
|
25
26
|
#
|
26
|
-
def initialize(status, timeout)
|
27
|
+
def initialize(status, timeout, timeout_duration)
|
27
28
|
super(status)
|
28
29
|
@timeout = timeout
|
30
|
+
@timeout_duration = timeout_duration
|
29
31
|
end
|
30
32
|
|
33
|
+
# The secs the command ran before being killed OR o or nil for no timeout
|
34
|
+
# @example
|
35
|
+
# status.timeout_duration #=> 10
|
36
|
+
# @return [Numeric, nil]
|
37
|
+
attr_reader :timeout_duration
|
38
|
+
|
31
39
|
# @!attribute [r] timeout?
|
32
|
-
#
|
33
40
|
# True if the process timed out and was sent the SIGKILL signal
|
34
|
-
#
|
35
41
|
# @example
|
36
42
|
# status = ProcessExecuter.spawn('sleep 10', timeout: 0.01)
|
37
43
|
# status.timeout? # => true
|
38
|
-
#
|
39
44
|
# @return [Boolean]
|
40
45
|
#
|
41
|
-
# @api public
|
42
|
-
#
|
43
46
|
def timeout? = @timeout
|
47
|
+
|
48
|
+
# Overrides the default success? method to return nil if the process timed out
|
49
|
+
#
|
50
|
+
# This is because when a timeout occurs, Windows will still return true
|
51
|
+
# @example
|
52
|
+
# status = ProcessExecuter.spawn('sleep 10', timeout: 0.01)
|
53
|
+
# status.success? # => nil
|
54
|
+
# @return [Boolean, nil]
|
55
|
+
#
|
56
|
+
def success?
|
57
|
+
return nil if timeout? # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
|
58
|
+
|
59
|
+
super
|
60
|
+
end
|
61
|
+
|
62
|
+
# Return a string representation of the status
|
63
|
+
# @example
|
64
|
+
# status.to_s #=> "pid 70144 SIGKILL (signal 9) timed out after 10s"
|
65
|
+
# @return [String]
|
66
|
+
def to_s
|
67
|
+
"#{super}#{timeout? ? " timed out after #{timeout_duration}s" : ''}"
|
68
|
+
end
|
44
69
|
end
|
45
70
|
end
|
data/lib/process_executer.rb
CHANGED
@@ -2,27 +2,43 @@
|
|
2
2
|
|
3
3
|
require 'process_executer/monitored_pipe'
|
4
4
|
require 'process_executer/options'
|
5
|
+
require 'process_executer/command'
|
5
6
|
require 'process_executer/status'
|
6
7
|
|
8
|
+
require 'logger'
|
7
9
|
require 'timeout'
|
8
10
|
|
9
|
-
#
|
11
|
+
# The `ProcessExecuter` module provides methods to execute subprocess commands
|
12
|
+
# with enhanced features such as output capture, timeout handling, and custom
|
13
|
+
# environment variables.
|
14
|
+
#
|
15
|
+
# Methods:
|
16
|
+
# * {run}: Executes a command and captures its output and status in a result object.
|
17
|
+
# * {spawn}: Executes a command and returns its exit status.
|
18
|
+
#
|
19
|
+
# Features:
|
20
|
+
# * Supports executing commands via a shell or directly.
|
21
|
+
# * Captures stdout and stderr to buffers, files, or custom objects.
|
22
|
+
# * Optionally enforces timeouts and terminates long-running commands.
|
23
|
+
# * Provides detailed status information, including success, failure, or timeout states.
|
10
24
|
#
|
11
25
|
# @api public
|
12
26
|
#
|
13
27
|
module ProcessExecuter
|
14
|
-
# Execute the
|
28
|
+
# Execute the given command as a subprocess and return the exit status
|
15
29
|
#
|
16
|
-
# This is a convenience method that calls
|
17
|
-
#
|
30
|
+
# This is a convenience method that calls
|
31
|
+
# [Process.spawn](https://docs.ruby-lang.org/en/3.3/Process.html#method-c-spawn)
|
32
|
+
# and blocks until the command terminates.
|
18
33
|
#
|
19
|
-
# The command will be
|
34
|
+
# The command will be sent the SIGKILL signal if it does not terminate within
|
20
35
|
# the specified timeout.
|
21
36
|
#
|
22
37
|
# @example
|
23
38
|
# status = ProcessExecuter.spawn('echo hello')
|
24
39
|
# status.exited? # => true
|
25
40
|
# status.success? # => true
|
41
|
+
# status.timeout? # => false
|
26
42
|
#
|
27
43
|
# @example with a timeout
|
28
44
|
# status = ProcessExecuter.spawn('sleep 10', timeout: 0.01)
|
@@ -30,6 +46,7 @@ module ProcessExecuter
|
|
30
46
|
# status.success? # => nil
|
31
47
|
# status.signaled? # => true
|
32
48
|
# status.termsig # => 9
|
49
|
+
# status.timeout? # => true
|
33
50
|
#
|
34
51
|
# @example capturing stdout to a string
|
35
52
|
# stdout = StringIO.new
|
@@ -42,10 +59,10 @@ module ProcessExecuter
|
|
42
59
|
# @see ProcessExecuter::Options#initialize See ProcessExecuter::Options#initialize
|
43
60
|
# for options that may be specified
|
44
61
|
#
|
45
|
-
# @param command [Array<String>]
|
46
|
-
# @param options_hash [Hash]
|
62
|
+
# @param command [Array<String>] The command to execute
|
63
|
+
# @param options_hash [Hash] The options to use when executing the command
|
47
64
|
#
|
48
|
-
# @return [Process::Status] the exit status of the
|
65
|
+
# @return [Process::Status] the exit status of the process
|
49
66
|
#
|
50
67
|
def self.spawn(*command, **options_hash)
|
51
68
|
options = ProcessExecuter::Options.new(**options_hash)
|
@@ -53,23 +70,210 @@ module ProcessExecuter
|
|
53
70
|
wait_for_process(pid, options)
|
54
71
|
end
|
55
72
|
|
73
|
+
# Execute the given command as a subprocess, blocking until it finishes
|
74
|
+
#
|
75
|
+
# Returns a result object which includes the process's status and output.
|
76
|
+
#
|
77
|
+
# Supports the same features as
|
78
|
+
# [Process.spawn](https://docs.ruby-lang.org/en/3.3/Process.html#method-c-spawn).
|
79
|
+
# In addition, it:
|
80
|
+
#
|
81
|
+
# 1. Blocks until the command exits
|
82
|
+
# 2. Captures stdout and stderr to a buffer or file
|
83
|
+
# 3. Optionally kills the command if it exceeds a timeout
|
84
|
+
#
|
85
|
+
# This method takes two forms:
|
86
|
+
#
|
87
|
+
# 1. The command is executed via a shell when the command is given as a single
|
88
|
+
# string:
|
89
|
+
#
|
90
|
+
# `ProcessExecuter.run([env, ] command_line, options = {}) ->` {ProcessExecuter::Command::Result}
|
91
|
+
#
|
92
|
+
# 2. The command is executed directly (bypassing the shell) when the command and it
|
93
|
+
# arguments are given as an array of strings:
|
94
|
+
#
|
95
|
+
# `ProcessExecuter.run([env, ] exe_path, *args, options = {}) ->` {ProcessExecuter::Command::Result}
|
96
|
+
#
|
97
|
+
# Optional argument `env` is a hash that affects ENV for the new process; see
|
98
|
+
# [Execution
|
99
|
+
# Environment](https://docs.ruby-lang.org/en/3.3/Process.html#module-Process-label-Execution+Environment).
|
100
|
+
#
|
101
|
+
# Argument `options` is a hash of options for the new process. See the options listed below.
|
102
|
+
#
|
103
|
+
# @example Run a command given as a single string (uses shell)
|
104
|
+
# # The command must be properly shell escaped when passed as a single string.
|
105
|
+
# command = 'echo "stdout: `pwd`"" && echo "stderr: $HOME" 1>&2'
|
106
|
+
# result = ProcessExecuter.run(command)
|
107
|
+
# result.success? #=> true
|
108
|
+
# result.stdout.string #=> "stdout: /Users/james/projects/main-branch/process_executer\n"
|
109
|
+
# result.stderr.string #=> "stderr: /Users/james\n"
|
110
|
+
#
|
111
|
+
# @example Run a command given as an array of strings (does not use shell)
|
112
|
+
# # The command and its args must be provided as separate strings in the array.
|
113
|
+
# # Shell expansions and redirections are not supported.
|
114
|
+
# command = ['git', 'clone', 'https://github.com/main-branch/process_executer']
|
115
|
+
# result = ProcessExecuter.run(*command)
|
116
|
+
# result.success? #=> true
|
117
|
+
# result.stdout.string #=> ""
|
118
|
+
# result.stderr.string #=> "Cloning into 'process_executer'...\n"
|
119
|
+
#
|
120
|
+
# @example Run a command with a timeout
|
121
|
+
# command = ['sleep', '1']
|
122
|
+
# result = ProcessExecuter.run(*command, timeout: 0.01)
|
123
|
+
# #=> raises ProcessExecuter::Command::TimeoutError which contains the command result
|
124
|
+
#
|
125
|
+
# @example Run a command which fails
|
126
|
+
# command = ['exit 1']
|
127
|
+
# result = ProcessExecuter.run(*command)
|
128
|
+
# #=> raises ProcessExecuter::Command::FailedError which contains the command result
|
129
|
+
#
|
130
|
+
# @example Run a command which exits due to an unhandled signal
|
131
|
+
# command = ['kill -9 $$']
|
132
|
+
# result = ProcessExecuter.run(*command)
|
133
|
+
# #=> raises ProcessExecuter::Command::SignaledError which contains the command result
|
134
|
+
#
|
135
|
+
# @example Return a result instead of raising an error when `raise_errors` is `false`
|
136
|
+
# # By setting `raise_errors` to `false`, exceptions will not be raised even
|
137
|
+
# # if the command fails.
|
138
|
+
# command = ['echo "Some error" 1>&2 && exit 1']
|
139
|
+
# result = ProcessExecuter.run(*command, raise_errors: false)
|
140
|
+
# # An error is not raised
|
141
|
+
# result.success? #=> false
|
142
|
+
# result.exitstatus #=> 1
|
143
|
+
# result.stdout.string #=> ""
|
144
|
+
# result.stderr.string #=> "Some error\n"
|
145
|
+
#
|
146
|
+
# @example Set environment variables
|
147
|
+
# env = { 'FOO' => 'foo', 'BAR' => 'bar' }
|
148
|
+
# command = 'echo "$FOO$BAR"'
|
149
|
+
# result = ProcessExecuter.run(env, *command)
|
150
|
+
# result.stdout.string #=> "foobar\n"
|
151
|
+
#
|
152
|
+
# @example Set environment variables when using a command array
|
153
|
+
# env = { 'GIT_DIR' => '/path/to/.git' }
|
154
|
+
# command = ['git', 'status']
|
155
|
+
# result = ProcessExecuter.run(env, *command)
|
156
|
+
# result.stdout.string #=> "On branch main\nYour branch is ..."
|
157
|
+
#
|
158
|
+
# @example Unset environment variables
|
159
|
+
# env = { 'GIT_DIR' => nil } # setting to nil unsets the variable in the environment
|
160
|
+
# command = ['git', 'status']
|
161
|
+
# result = ProcessExecuter.run(env, *command)
|
162
|
+
# result.stdout.string #=> "On branch main\nYour branch is ..."
|
163
|
+
#
|
164
|
+
# @example Reset existing environment variables and add new ones
|
165
|
+
# env = { 'PATH' => '/bin' }
|
166
|
+
# result = ProcessExecuter.run(env, 'echo "Home: $HOME" && echo "Path: $PATH"', unsetenv_others: true)
|
167
|
+
# result.stdout.string #=> "Home: \n/Path: /bin\n"
|
168
|
+
#
|
169
|
+
# @example Run command in a different directory
|
170
|
+
# command = ['pwd']
|
171
|
+
# result = ProcessExecuter.run(*command, chdir: '/tmp')
|
172
|
+
# result.stdout.string #=> "/tmp\n"
|
173
|
+
#
|
174
|
+
# @example Capture stdout and stderr into a single buffer
|
175
|
+
# command = ['echo "stdout" && echo "stderr" 1>&2']
|
176
|
+
# result = ProcessExecuter.run(*command, merge: true)
|
177
|
+
# result.stdout.string #=> "stdout\nstderr\n"
|
178
|
+
# result.stdout.object_id == result.stderr.object_id #=> true
|
179
|
+
#
|
180
|
+
# @example Capture to an explicit buffer
|
181
|
+
# out = StringIO.new
|
182
|
+
# err = StringIO.new
|
183
|
+
# command = ['echo "stdout" && echo "stderr" 1>&2']
|
184
|
+
# result = ProcessExecuter.run(*command, out: out, err: err)
|
185
|
+
# out.string #=> "stdout\n"
|
186
|
+
# err.string #=> "stderr\n"
|
187
|
+
# result.stdout.object_id == out.object_id #=> true
|
188
|
+
# result.stderr.object_id == err.object_id #=> true
|
189
|
+
#
|
190
|
+
# @example Capture to a file
|
191
|
+
# # Same technique can be used for stderr
|
192
|
+
# out = File.open('stdout.txt', 'w')
|
193
|
+
# command = ['echo "stdout" && echo "stderr" 1>&2']
|
194
|
+
# result = ProcessExecuter.run(*command, out: out, err: err)
|
195
|
+
# out.close
|
196
|
+
# File.read('stdout.txt') #=> "stdout\n"
|
197
|
+
# # stderr is still captured to a StringIO buffer internally
|
198
|
+
# result.stderr.string #=> "stderr\n"
|
199
|
+
#
|
200
|
+
# @example Capture to multiple writers (e.g. files, buffers, STDOUT, etc.)
|
201
|
+
# # Same technique can be used for stderr
|
202
|
+
# out_buffer = StringIO.new
|
203
|
+
# out_file = File.open('stdout.txt', 'w')
|
204
|
+
# command = ['echo "stdout" && echo "stderr" 1>&2']
|
205
|
+
# result = ProcessExecuter.run(*command, out: [out_buffer, out_file])
|
206
|
+
# # You must manage closing resources you create yourself
|
207
|
+
# out_file.close
|
208
|
+
# out_buffer.string #=> "stdout\n"
|
209
|
+
# File.read('stdout.txt') #=> "stdout\n"
|
210
|
+
#
|
211
|
+
# @param command [Array<String>] The command to run
|
212
|
+
#
|
213
|
+
# If the first element of command is a Hash, it is added to the ENV of
|
214
|
+
# the new process. See [Execution Environment](https://ruby-doc.org/3.3.6/Process.html#module-Process-label-Execution+Environment)
|
215
|
+
# for more details. The env hash is then removed from the command array.
|
216
|
+
#
|
217
|
+
# If the first and only (remaining) command element is a string, it is passed to
|
218
|
+
# a subshell if it begins with a shell reserved word, contains special built-ins,
|
219
|
+
# or includes shell metacharacters.
|
220
|
+
#
|
221
|
+
# Care must be taken to properly escape shell metacharacters in the command string.
|
222
|
+
#
|
223
|
+
# Otherwise, the command is run bypassing the shell. When bypassing the shell, shell expansions
|
224
|
+
# and redirections are not supported.
|
225
|
+
#
|
226
|
+
# @param logger [Logger] The logger to use
|
227
|
+
# @param options_hash [Hash] Additional options
|
228
|
+
# @option options_hash [Numeric] :timeout The maximum seconds to wait for the command to complete
|
229
|
+
#
|
230
|
+
# If timeout is zero or nil, the command will not time out. If the command
|
231
|
+
# times out, it is killed via a SIGKILL signal and {ProcessExecuter::Command::TimeoutError} is raised.
|
232
|
+
#
|
233
|
+
# If the command does not exit when receiving the SIGKILL signal, this method may hang indefinitely.
|
234
|
+
#
|
235
|
+
# @option options_hash [#write] :out (nil) The object to write stdout to
|
236
|
+
# @option options_hash [#write] :err (nil) The object to write stderr to
|
237
|
+
# @option options_hash [Boolean] :merge (false) If true, stdout and stderr are written to the same capture buffer
|
238
|
+
# @option options_hash [Boolean] :raise_errors (true) Raise an exception if the command fails
|
239
|
+
# @option options_hash [Boolean] :unsetenv_others (false) If true, unset all environment variables before
|
240
|
+
# applying the new ones
|
241
|
+
# @option options_hash [true, Integer, nil] :pgroup (nil) true or 0: new process group; non-zero: join
|
242
|
+
# the group, nil: existing group
|
243
|
+
# @option options_hash [Boolean] :new_pgroup (nil) Create a new process group (Windows only)
|
244
|
+
# @option options_hash [Integer] :rlimit_resource_name (nil) Set resource limits (see Process.setrlimit)
|
245
|
+
# @option options_hash [Integer] :umask (nil) Set the umask (see File.umask)
|
246
|
+
# @option options_hash [Boolean] :close_others (false) If true, close non-standard file descriptors
|
247
|
+
# @option options_hash [String] :chdir (nil) The directory to run the command in
|
248
|
+
#
|
249
|
+
# @raise [ProcessExecuter::Command::FailedError] if the command returned a non-zero exit status
|
250
|
+
# @raise [ProcessExecuter::Command::SignaledError] if the command exited because of an unhandled signal
|
251
|
+
# @raise [ProcessExecuter::Command::TimeoutError] if the command timed out
|
252
|
+
# @raise [ProcessExecuter::Command::ProcessIOError] if an exception was raised while collecting subprocess output
|
253
|
+
#
|
254
|
+
# @return [ProcessExecuter::Command::Result] A result object containing the process status and captured output
|
255
|
+
#
|
256
|
+
def self.run(*command, logger: Logger.new(nil), **options_hash)
|
257
|
+
ProcessExecuter::Command::Runner.new(logger).call(*command, **options_hash)
|
258
|
+
end
|
259
|
+
|
56
260
|
# Wait for process to terminate
|
57
261
|
#
|
58
|
-
# If a timeout is
|
262
|
+
# If a timeout is specified in options, terminate the process after options.timeout seconds.
|
59
263
|
#
|
60
|
-
# @param pid [Integer] the process
|
264
|
+
# @param pid [Integer] the process ID
|
61
265
|
# @param options [ProcessExecuter::Options] the options used
|
62
266
|
#
|
63
|
-
# @return [ProcessExecuter::Status] the status
|
267
|
+
# @return [ProcessExecuter::Status] the process status including Process::Status attributes and a timeout flag
|
64
268
|
#
|
65
269
|
# @api private
|
66
270
|
#
|
67
271
|
private_class_method def self.wait_for_process(pid, options)
|
68
272
|
Timeout.timeout(options.timeout) do
|
69
|
-
ProcessExecuter::Status.new(Process.wait2(pid).last, false)
|
273
|
+
ProcessExecuter::Status.new(Process.wait2(pid).last, false, options.timeout)
|
70
274
|
end
|
71
275
|
rescue Timeout::Error
|
72
276
|
Process.kill('KILL', pid)
|
73
|
-
ProcessExecuter::Status.new(Process.wait2(pid).last, true)
|
277
|
+
ProcessExecuter::Status.new(Process.wait2(pid).last, true, options.timeout)
|
74
278
|
end
|
75
279
|
end
|
data/package.json
ADDED
data/process_executer.gemspec
CHANGED
@@ -10,16 +10,17 @@ Gem::Specification.new do |spec|
|
|
10
10
|
|
11
11
|
spec.summary = 'An API for executing commands in a subprocess'
|
12
12
|
spec.description = 'An API for executing commands in a subprocess'
|
13
|
-
spec.homepage = 'https://github.com/main-branch/process_executer'
|
14
13
|
spec.license = 'MIT'
|
15
|
-
spec.required_ruby_version = '>= 3.
|
14
|
+
spec.required_ruby_version = '>= 3.1.0'
|
16
15
|
|
17
16
|
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
18
17
|
|
18
|
+
# Project links
|
19
|
+
spec.homepage = "https://github.com/main-branch/#{spec.name}"
|
19
20
|
spec.metadata['homepage_uri'] = spec.homepage
|
20
|
-
spec.metadata['source_code_uri'] =
|
21
|
-
spec.metadata['changelog_uri'] = "https://rubydoc.info/gems/#{spec.name}/#{spec.version}/file/CHANGELOG.md"
|
21
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
22
22
|
spec.metadata['documentation_uri'] = "https://rubydoc.info/gems/#{spec.name}/#{spec.version}"
|
23
|
+
spec.metadata['changelog_uri'] = "https://rubydoc.info/gems/#{spec.name}/#{spec.version}/file/CHANGELOG.md"
|
23
24
|
|
24
25
|
# Specify which files should be added to the gem when it is released.
|
25
26
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
@@ -31,15 +32,21 @@ Gem::Specification.new do |spec|
|
|
31
32
|
spec.bindir = 'exe'
|
32
33
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
33
34
|
spec.require_paths = ['lib']
|
35
|
+
spec.requirements = [
|
36
|
+
'Platform: Mac, Linux, or Windows',
|
37
|
+
'Ruby: MRI 3.1 or later, TruffleRuby 24 or later, or JRuby 9.4 or later'
|
38
|
+
]
|
34
39
|
|
35
40
|
spec.add_development_dependency 'bundler-audit', '~> 0.9'
|
36
|
-
spec.add_development_dependency 'create_github_release', '~>
|
37
|
-
spec.add_development_dependency '
|
38
|
-
spec.add_development_dependency '
|
39
|
-
spec.add_development_dependency '
|
41
|
+
spec.add_development_dependency 'create_github_release', '~> 2.1'
|
42
|
+
spec.add_development_dependency 'main_branch_shared_rubocop_config', '~> 0.1'
|
43
|
+
spec.add_development_dependency 'rake', '~> 13.2'
|
44
|
+
spec.add_development_dependency 'rspec', '~> 3.13'
|
45
|
+
spec.add_development_dependency 'rubocop', '~> 1.66'
|
40
46
|
spec.add_development_dependency 'semverify', '~> 0.3'
|
41
47
|
spec.add_development_dependency 'simplecov', '~> 0.22'
|
42
48
|
spec.add_development_dependency 'simplecov-lcov', '~> 0.8'
|
49
|
+
spec.add_development_dependency 'simplecov-rspec', '~> 0.3'
|
43
50
|
|
44
51
|
unless RUBY_PLATFORM == 'java'
|
45
52
|
spec.add_development_dependency 'redcarpet', '~> 3.6'
|