process_executer 2.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +159 -46
- data/lib/process_executer/destination_base.rb +83 -0
- data/lib/process_executer/destinations/child_redirection.rb +23 -0
- data/lib/process_executer/destinations/close.rb +23 -0
- data/lib/process_executer/destinations/file_descriptor.rb +36 -0
- data/lib/process_executer/destinations/file_path.rb +56 -0
- data/lib/process_executer/destinations/file_path_mode.rb +60 -0
- data/lib/process_executer/destinations/file_path_mode_perms.rb +61 -0
- data/lib/process_executer/destinations/io.rb +33 -0
- data/lib/process_executer/destinations/monitored_pipe.rb +39 -0
- data/lib/process_executer/destinations/stderr.rb +31 -0
- data/lib/process_executer/destinations/stdout.rb +31 -0
- data/lib/process_executer/destinations/tee.rb +60 -0
- data/lib/process_executer/destinations/writer.rb +33 -0
- data/lib/process_executer/destinations.rb +70 -0
- data/lib/process_executer/errors.rb +11 -1
- data/lib/process_executer/monitored_pipe.rb +40 -57
- data/lib/process_executer/options/base.rb +240 -0
- data/lib/process_executer/options/option_definition.rb +56 -0
- data/lib/process_executer/options/run_options.rb +48 -0
- data/lib/process_executer/options/spawn_and_wait_options.rb +39 -0
- data/lib/process_executer/options/spawn_options.rb +143 -0
- data/lib/process_executer/options.rb +7 -166
- data/lib/process_executer/result.rb +13 -23
- data/lib/process_executer/runner.rb +60 -56
- data/lib/process_executer/version.rb +1 -1
- data/lib/process_executer.rb +136 -26
- metadata +23 -4
@@ -1,171 +1,12 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
require_relative 'options/base'
|
4
|
+
require_relative 'options/spawn_options'
|
5
|
+
require_relative 'options/spawn_and_wait_options'
|
6
|
+
require_relative 'options/run_options'
|
7
|
+
require_relative 'options/option_definition'
|
4
8
|
|
5
9
|
module ProcessExecuter
|
6
|
-
#
|
7
|
-
|
8
|
-
# Valid options are those accepted by Process.spawn plus the following additions:
|
9
|
-
#
|
10
|
-
# * `:timeout_after`: the number of seconds to allow a process to run before killing it
|
11
|
-
#
|
12
|
-
# @api public
|
13
|
-
#
|
14
|
-
class Options
|
15
|
-
# :nocov:
|
16
|
-
# SimpleCov on JRuby seems to hav a bug that causes hashes declared on multiple lines
|
17
|
-
# to not be counted as covered.
|
18
|
-
|
19
|
-
# These options should be passed to `Process.spawn`
|
20
|
-
#
|
21
|
-
# Additionally, any options whose key is an Integer or an IO object will
|
22
|
-
# be passed to `Process.spawn`.
|
23
|
-
#
|
24
|
-
SPAWN_OPTIONS = %i[
|
25
|
-
in out err unsetenv_others pgroup new_pgroup rlimit_resourcename umask
|
26
|
-
close_others chdir
|
27
|
-
].freeze
|
28
|
-
|
29
|
-
# These options are allowed by `ProcessExecuter.spawn` but should NOT be passed
|
30
|
-
# to `Process.spawn`
|
31
|
-
#
|
32
|
-
NON_SPAWN_OPTIONS = %i[
|
33
|
-
timeout_after raise_errors
|
34
|
-
].freeze
|
35
|
-
|
36
|
-
# Any `SPAWN_OPTIONS` set to `NOT_SET` will not be passed to `Process.spawn`
|
37
|
-
#
|
38
|
-
NOT_SET = :not_set
|
39
|
-
|
40
|
-
# The default values for all options
|
41
|
-
# @return [Hash]
|
42
|
-
DEFAULTS = {
|
43
|
-
in: NOT_SET,
|
44
|
-
out: NOT_SET,
|
45
|
-
err: NOT_SET,
|
46
|
-
unsetenv_others: NOT_SET,
|
47
|
-
pgroup: NOT_SET,
|
48
|
-
new_pgroup: NOT_SET,
|
49
|
-
rlimit_resourcename: NOT_SET,
|
50
|
-
umask: NOT_SET,
|
51
|
-
close_others: NOT_SET,
|
52
|
-
chdir: NOT_SET,
|
53
|
-
raise_errors: true,
|
54
|
-
timeout_after: nil
|
55
|
-
}.freeze
|
56
|
-
|
57
|
-
# :nocov:
|
58
|
-
|
59
|
-
# All options allowed by this class
|
60
|
-
#
|
61
|
-
ALL_OPTIONS = (SPAWN_OPTIONS + NON_SPAWN_OPTIONS).freeze
|
62
|
-
|
63
|
-
# Create accessor functions for all options. Assumes that the options are stored
|
64
|
-
# in a hash named `@options`
|
65
|
-
#
|
66
|
-
ALL_OPTIONS.each do |option|
|
67
|
-
define_method(option) do
|
68
|
-
@options[option]
|
69
|
-
end
|
70
|
-
end
|
71
|
-
|
72
|
-
# Create a new Options object
|
73
|
-
#
|
74
|
-
# @example
|
75
|
-
# options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout_after: 10)
|
76
|
-
#
|
77
|
-
# @param options [Hash] Process.spawn options plus additional options listed below.
|
78
|
-
#
|
79
|
-
# See [Process.spawn](https://ruby-doc.org/core/Process.html#method-c-spawn)
|
80
|
-
# for a list of valid options that can be passed to `Process.spawn`.
|
81
|
-
#
|
82
|
-
# @option options [Integer, Float, nil] :timeout_after
|
83
|
-
# Number of seconds to wait for the process to terminate. Any number
|
84
|
-
# may be used, including Floats to specify fractional seconds. A value of 0 or nil
|
85
|
-
# will allow the process to run indefinitely.
|
86
|
-
#
|
87
|
-
def initialize(**options)
|
88
|
-
assert_no_unknown_options(options)
|
89
|
-
@options = DEFAULTS.merge(options)
|
90
|
-
assert_timeout_is_valid
|
91
|
-
end
|
92
|
-
|
93
|
-
# Returns the options to be passed to Process.spawn
|
94
|
-
#
|
95
|
-
# @example
|
96
|
-
# options = ProcessExecuter::Options.new(out: $stdout, err: $stderr, timeout_after: 10)
|
97
|
-
# options.spawn_options # => { out: $stdout, err: $stderr }
|
98
|
-
#
|
99
|
-
# @return [Hash]
|
100
|
-
#
|
101
|
-
def spawn_options
|
102
|
-
{}.tap do |spawn_options|
|
103
|
-
options.each do |option, value|
|
104
|
-
spawn_options[option] = value if include_spawn_option?(option, value)
|
105
|
-
end
|
106
|
-
end
|
107
|
-
end
|
108
|
-
|
109
|
-
private
|
110
|
-
|
111
|
-
# @!attribute [r]
|
112
|
-
#
|
113
|
-
# Options with values
|
114
|
-
#
|
115
|
-
# All options have values. If an option is not given in the initializer, it
|
116
|
-
# will have the value `NOT_SET`.
|
117
|
-
#
|
118
|
-
# @return [Hash<Symbol, Object>]
|
119
|
-
#
|
120
|
-
# @api private
|
121
|
-
#
|
122
|
-
attr_reader :options
|
123
|
-
|
124
|
-
# Determine if the options hash contains any unknown options
|
125
|
-
# @param options [Hash] the hash of options
|
126
|
-
# @return [void]
|
127
|
-
# @raise [ArgumentError] if the options hash contains any unknown options
|
128
|
-
# @api private
|
129
|
-
def assert_no_unknown_options(options)
|
130
|
-
unknown_options = options.keys.reject { |key| valid_option?(key) }
|
131
|
-
raise ArgumentError, "Unknown options: #{unknown_options.join(', ')}" unless unknown_options.empty?
|
132
|
-
end
|
133
|
-
|
134
|
-
# Raise an error if timeout_after is not a non-negative real number
|
135
|
-
# @return [void]
|
136
|
-
# @raise [ArgumentError] if timeout_after is not a non-negative real number
|
137
|
-
# @api private
|
138
|
-
def assert_timeout_is_valid
|
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?
|
143
|
-
|
144
|
-
raise ArgumentError, invalid_timeout_after_message
|
145
|
-
end
|
146
|
-
|
147
|
-
# The message to be used when raising an error for an invalid timeout_after
|
148
|
-
# @return [String]
|
149
|
-
# @api private
|
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}"
|
152
|
-
end
|
153
|
-
|
154
|
-
# Determine if the given option is a valid option
|
155
|
-
# @param option [Symbol] the option to be tested
|
156
|
-
# @return [Boolean] true if the given option is a valid option
|
157
|
-
# @api private
|
158
|
-
def valid_option?(option)
|
159
|
-
ALL_OPTIONS.include?(option) || option.is_a?(Integer) || option.respond_to?(:fileno)
|
160
|
-
end
|
161
|
-
|
162
|
-
# Determine if the given option should be passed to `Process.spawn`
|
163
|
-
# @param option [Symbol, Integer, IO] the option to be tested
|
164
|
-
# @param value [Object] the value of the option
|
165
|
-
# @return [Boolean] true if the given option should be passed to `Process.spawn`
|
166
|
-
# @api private
|
167
|
-
def include_spawn_option?(option, value)
|
168
|
-
(option.is_a?(Integer) || option.is_a?(IO) || SPAWN_OPTIONS.include?(option)) && value != NOT_SET
|
169
|
-
end
|
170
|
-
end
|
10
|
+
# Options related to spawning or running a command
|
11
|
+
module Options; end
|
171
12
|
end
|
@@ -80,7 +80,9 @@ module ProcessExecuter
|
|
80
80
|
# result.timed_out? # => true
|
81
81
|
# @return [Boolean]
|
82
82
|
#
|
83
|
-
def timed_out?
|
83
|
+
def timed_out?
|
84
|
+
@timed_out
|
85
|
+
end
|
84
86
|
|
85
87
|
# Overrides the default success? method to return nil if the process timed out
|
86
88
|
#
|
@@ -107,9 +109,8 @@ module ProcessExecuter
|
|
107
109
|
|
108
110
|
# Return the captured stdout output
|
109
111
|
#
|
110
|
-
# This output is only returned if the `:out` option
|
111
|
-
# `ProcessExecuter::MonitoredPipe
|
112
|
-
# method (e.g. a StringIO).
|
112
|
+
# This output is only returned if the `:out` option value is a
|
113
|
+
# `ProcessExecuter::MonitoredPipe`.
|
113
114
|
#
|
114
115
|
# @example
|
115
116
|
# # Note that `ProcessExecuter.run` will wrap the given out: object in a
|
@@ -120,22 +121,16 @@ module ProcessExecuter
|
|
120
121
|
# @return [String, nil]
|
121
122
|
#
|
122
123
|
def stdout
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
pipe.writers.each do |writer|
|
127
|
-
return writer.string if writer.respond_to?(:string)
|
128
|
-
end
|
129
|
-
end
|
124
|
+
pipe = options.stdout_redirection_value
|
125
|
+
return nil unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
|
130
126
|
|
131
|
-
|
127
|
+
pipe.destination.string
|
132
128
|
end
|
133
129
|
|
134
130
|
# Return the captured stderr output
|
135
131
|
#
|
136
|
-
# This output is only returned if the `:err` option
|
137
|
-
# `ProcessExecuter::MonitoredPipe
|
138
|
-
# method (e.g. a StringIO).
|
132
|
+
# This output is only returned if the `:err` option value is a
|
133
|
+
# `ProcessExecuter::MonitoredPipe`.
|
139
134
|
#
|
140
135
|
# @example
|
141
136
|
# # Note that `ProcessExecuter.run` will wrap the given err: object in a
|
@@ -146,15 +141,10 @@ module ProcessExecuter
|
|
146
141
|
# @return [String, nil]
|
147
142
|
#
|
148
143
|
def stderr
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
pipe.writers.each do |writer|
|
153
|
-
return writer.string if writer.respond_to?(:string)
|
154
|
-
end
|
155
|
-
end
|
144
|
+
pipe = options.stderr_redirection_value
|
145
|
+
return nil unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
|
156
146
|
|
157
|
-
|
147
|
+
pipe.destination.string
|
158
148
|
end
|
159
149
|
end
|
160
150
|
end
|
@@ -15,24 +15,6 @@ module ProcessExecuter
|
|
15
15
|
# @api public
|
16
16
|
#
|
17
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
18
|
# Run a command and return the status including stdout and stderr output
|
37
19
|
#
|
38
20
|
# @example
|
@@ -45,20 +27,12 @@ module ProcessExecuter
|
|
45
27
|
# result.stderr # => ""
|
46
28
|
#
|
47
29
|
# @param command [Array<String>] The command to run
|
48
|
-
# @param
|
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.
|
30
|
+
# @param options [ProcessExecuter::Options::RunOptions] Options for running the command
|
54
31
|
#
|
55
32
|
# @return [ProcessExecuter::Result] The result of the completed subprocess
|
56
33
|
#
|
57
|
-
def call(
|
58
|
-
|
59
|
-
err ||= (merge ? out : StringIO.new)
|
60
|
-
|
61
|
-
spawn(command, out:, err:, **options_hash).tap { |result| process_result(result) }
|
34
|
+
def call(command, options)
|
35
|
+
spawn(command, options).tap { |result| process_result(result) }
|
62
36
|
end
|
63
37
|
|
64
38
|
private
|
@@ -66,30 +40,55 @@ module ProcessExecuter
|
|
66
40
|
# Wrap the output buffers in pipes and then execute the command
|
67
41
|
#
|
68
42
|
# @param command [Array<String>] The command to execute
|
69
|
-
# @param
|
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.
|
43
|
+
# @param options [ProcessExecuter::Options::RunOptions] Options for running the command
|
74
44
|
#
|
75
|
-
# @raise [ProcessExecuter::
|
76
|
-
# @raise [ProcessExecuter::TimeoutError] If the command times out
|
45
|
+
# @raise [ProcessExecuter::Error] if the command could not be executed or failed
|
77
46
|
#
|
78
47
|
# @return [ProcessExecuter::Result] The result of the completed subprocess
|
79
48
|
#
|
80
49
|
# @api private
|
81
50
|
#
|
82
|
-
def spawn(command,
|
83
|
-
|
84
|
-
|
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)
|
51
|
+
def spawn(command, options)
|
52
|
+
opened_pipes = wrap_stdout_stderr(options)
|
53
|
+
ProcessExecuter.spawn_and_wait_with_options(command, options)
|
88
54
|
ensure
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
55
|
+
opened_pipes.each { |key, value| close_pipe(command, key, value) }
|
56
|
+
end
|
57
|
+
|
58
|
+
# Wrap the stdout and stderr redirection options with a MonitoredPipe
|
59
|
+
# @param options [ProcessExecuter::Options::RunOptions] Options for running the command
|
60
|
+
# @return [Hash<Object, ProcessExecuter::MonitoredPipe>] The opened pipes (the Object is the option key)
|
61
|
+
# @api private
|
62
|
+
def wrap_stdout_stderr(options)
|
63
|
+
options.each_with_object({}) do |key_value, opened_pipes|
|
64
|
+
key, value = key_value
|
65
|
+
|
66
|
+
next unless should_wrap?(options, key, value)
|
67
|
+
|
68
|
+
wrapped_destination = ProcessExecuter::MonitoredPipe.new(value)
|
69
|
+
opened_pipes[key] = wrapped_destination
|
70
|
+
options.merge!(key => wrapped_destination)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Should the redirection option be wrapped by a MonitoredPipe
|
75
|
+
# @param key [Object] The option key
|
76
|
+
# @param value [Object] The option value
|
77
|
+
# @return [Boolean] Whether the option should be wrapped
|
78
|
+
# @api private
|
79
|
+
def should_wrap?(options, key, value)
|
80
|
+
(options.stdout_redirection?(key) || options.stderr_redirection?(key)) &&
|
81
|
+
ProcessExecuter::Destinations.compatible_with_monitored_pipe?(value)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Close the pipe and raise an error if the pipe raised an exception
|
85
|
+
# @return [void]
|
86
|
+
# @raise [ProcessExecuter::ProcessIOError] If an exception was raised while
|
87
|
+
# collecting subprocess output
|
88
|
+
# @api private
|
89
|
+
def close_pipe(command, option_key, pipe)
|
90
|
+
pipe.close
|
91
|
+
raise_pipe_error(command, option_key, pipe) if pipe.exception
|
93
92
|
end
|
94
93
|
|
95
94
|
# Process the result of the command and return a ProcessExecuter::Result
|
@@ -100,18 +99,23 @@ module ProcessExecuter
|
|
100
99
|
#
|
101
100
|
# @return [Void]
|
102
101
|
#
|
103
|
-
# @raise [ProcessExecuter::
|
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
|
102
|
+
# @raise [ProcessExecuter::Error] if the command could not be executed or failed
|
107
103
|
#
|
108
104
|
# @api private
|
109
105
|
#
|
110
106
|
def process_result(result)
|
111
107
|
log_result(result)
|
112
108
|
|
113
|
-
|
109
|
+
raise_errors(result) if result.options.raise_errors
|
110
|
+
end
|
114
111
|
|
112
|
+
# Raise an error if the command failed
|
113
|
+
# @return [void]
|
114
|
+
# @raise [ProcessExecuter::FailedError] If the command failed
|
115
|
+
# @raise [ProcessExecuter::SignaledError] If the command was signaled
|
116
|
+
# @raise [ProcessExecuter::TimeoutError] If the command times out
|
117
|
+
# @api private
|
118
|
+
def raise_errors(result)
|
115
119
|
raise TimeoutError, result if result.timed_out?
|
116
120
|
raise SignaledError, result if result.signaled?
|
117
121
|
raise FailedError, result unless result.success?
|
@@ -123,14 +127,14 @@ module ProcessExecuter
|
|
123
127
|
# @return [void]
|
124
128
|
# @api private
|
125
129
|
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}" }
|
130
|
+
result.options.logger.info { "#{result.command} exited with status #{result}" }
|
131
|
+
result.options.logger.debug { "stdout:\n#{result.stdout.inspect}\nstderr:\n#{result.stderr.inspect}" }
|
128
132
|
end
|
129
133
|
|
130
134
|
# Raise an error when there was exception while collecting the subprocess output
|
131
135
|
#
|
132
136
|
# @param command [Array<String>] The command that was executed
|
133
|
-
# @param
|
137
|
+
# @param option_key [Symbol] The name of the pipe that raised the exception
|
134
138
|
# @param pipe [ProcessExecuter::MonitoredPipe] The pipe that raised the exception
|
135
139
|
#
|
136
140
|
# @raise [ProcessExecuter::ProcessIOError]
|
@@ -139,8 +143,8 @@ module ProcessExecuter
|
|
139
143
|
#
|
140
144
|
# @api private
|
141
145
|
#
|
142
|
-
def raise_pipe_error(command,
|
143
|
-
error = ProcessExecuter::ProcessIOError.new("Pipe Exception for #{command}: #{
|
146
|
+
def raise_pipe_error(command, option_key, pipe)
|
147
|
+
error = ProcessExecuter::ProcessIOError.new("Pipe Exception for #{command}: #{option_key.inspect}")
|
144
148
|
raise(error, cause: pipe.exception)
|
145
149
|
end
|
146
150
|
end
|