process_executer 2.0.0 → 3.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.
@@ -1,171 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'pp'
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
- # Validate ProcessExecuter::Executer#spawn options and return Process.spawn options
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? = @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 was set to a
111
- # `ProcessExecuter::MonitoredPipe` that includes a writer that implements `#string`
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
- 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
124
+ pipe = options.stdout_redirection_value
125
+ return nil unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
130
126
 
131
- nil
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 was set to a
137
- # `ProcessExecuter::MonitoredPipe` that includes a writer that implements `#string`
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
- 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
144
+ pipe = options.stderr_redirection_value
145
+ return nil unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
156
146
 
157
- nil
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 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.
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(*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) }
34
+ def call(command, options)
35
+ spawn(command, options).tap { |result| process_result(result) }
62
36
  end
63
37
 
64
38
  private
@@ -66,11 +40,7 @@ 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 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.
43
+ # @param options [ProcessExecuter::Options::RunOptions] Options for running the command
74
44
  #
75
45
  # @raise [ProcessExecuter::ProcessIOError] If an exception was raised while collecting subprocess output
76
46
  # @raise [ProcessExecuter::TimeoutError] If the command times out
@@ -79,17 +49,47 @@ module ProcessExecuter
79
49
  #
80
50
  # @api private
81
51
  #
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)
52
+ def spawn(command, options)
53
+ opened_pipes = wrap_stdout_stderr(options)
54
+ ProcessExecuter.spawn_and_wait_with_options(command, options)
88
55
  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
56
+ opened_pipes.each { |key, value| close_pipe(command, key, value) }
57
+ end
58
+
59
+ # Wrap the stdout and stderr redirection options with a MonitoredPipe
60
+ # @param options [ProcessExecuter::Options::RunOptions] Options for running the command
61
+ # @return [Hash<Object, ProcessExecuter::MonitoredPipe>] The opened pipes (the Object is the option key)
62
+ # @api private
63
+ def wrap_stdout_stderr(options)
64
+ options.each_with_object({}) do |key_value, opened_pipes|
65
+ key, value = key_value
66
+
67
+ next unless should_wrap?(options, key, value)
68
+
69
+ wrapped_destination = ProcessExecuter::MonitoredPipe.new(value)
70
+ opened_pipes[key] = wrapped_destination
71
+ options.merge!(key => wrapped_destination)
72
+ end
73
+ end
74
+
75
+ # Should the redirection option be wrapped by a MonitoredPipe
76
+ # @param key [Object] The option key
77
+ # @param value [Object] The option value
78
+ # @return [Boolean] Whether the option should be wrapped
79
+ # @api private
80
+ def should_wrap?(options, key, value)
81
+ (options.stdout_redirection?(key) || options.stderr_redirection?(key)) &&
82
+ ProcessExecuter::Destinations.compatible_with_monitored_pipe?(value)
83
+ end
84
+
85
+ # Close the pipe and raise an error if the pipe raised an exception
86
+ # @return [void]
87
+ # @raise [ProcessExecuter::ProcessIOError] If an exception was raised while
88
+ # collecting subprocess output
89
+ # @api private
90
+ def close_pipe(command, option_key, pipe)
91
+ pipe.close
92
+ raise_pipe_error(command, option_key, pipe) if pipe.exception
93
93
  end
94
94
 
95
95
  # Process the result of the command and return a ProcessExecuter::Result
@@ -110,8 +110,16 @@ module ProcessExecuter
110
110
  def process_result(result)
111
111
  log_result(result)
112
112
 
113
- return unless result.options.raise_errors
113
+ raise_errors(result) if result.options.raise_errors
114
+ end
114
115
 
116
+ # Raise an error if the command failed
117
+ # @return [void]
118
+ # @raise [ProcessExecuter::FailedError] If the command failed
119
+ # @raise [ProcessExecuter::SignaledError] If the command was signaled
120
+ # @raise [ProcessExecuter::TimeoutError] If the command times out
121
+ # @api private
122
+ def raise_errors(result)
115
123
  raise TimeoutError, result if result.timed_out?
116
124
  raise SignaledError, result if result.signaled?
117
125
  raise FailedError, result unless result.success?
@@ -123,14 +131,14 @@ module ProcessExecuter
123
131
  # @return [void]
124
132
  # @api private
125
133
  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}" }
134
+ result.options.logger.info { "#{result.command} exited with status #{result}" }
135
+ result.options.logger.debug { "stdout:\n#{result.stdout.inspect}\nstderr:\n#{result.stderr.inspect}" }
128
136
  end
129
137
 
130
138
  # Raise an error when there was exception while collecting the subprocess output
131
139
  #
132
140
  # @param command [Array<String>] The command that was executed
133
- # @param pipe_name [Symbol] The name of the pipe that raised the exception
141
+ # @param option_key [Symbol] The name of the pipe that raised the exception
134
142
  # @param pipe [ProcessExecuter::MonitoredPipe] The pipe that raised the exception
135
143
  #
136
144
  # @raise [ProcessExecuter::ProcessIOError]
@@ -139,8 +147,8 @@ module ProcessExecuter
139
147
  #
140
148
  # @api private
141
149
  #
142
- def raise_pipe_error(command, pipe_name, pipe)
143
- error = ProcessExecuter::ProcessIOError.new("Pipe Exception for #{command}: #{pipe_name}")
150
+ def raise_pipe_error(command, option_key, pipe)
151
+ error = ProcessExecuter::ProcessIOError.new("Pipe Exception for #{command}: #{option_key.inspect}")
144
152
  raise(error, cause: pipe.exception)
145
153
  end
146
154
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ProcessExecuter
4
4
  # The current Gem version
5
- VERSION = '2.0.0'
5
+ VERSION = '3.0.0'
6
6
  end
@@ -1,25 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'logger'
4
+ require 'timeout'
5
+
6
+ require 'process_executer/destination_base'
7
+ require 'process_executer/destinations'
3
8
  require 'process_executer/errors'
4
9
  require 'process_executer/monitored_pipe'
5
10
  require 'process_executer/options'
6
11
  require 'process_executer/result'
7
12
  require 'process_executer/runner'
8
13
 
9
- require 'logger'
10
- require 'timeout'
11
-
12
14
  # The `ProcessExecuter` module provides methods to execute subprocess commands
13
15
  # with enhanced features such as output capture, timeout handling, and custom
14
16
  # environment variables.
15
17
  #
16
18
  # Methods:
19
+ #
17
20
  # * {run}: Executes a command and returns the result which includes the process
18
21
  # status and output
19
22
  # * {spawn_and_wait}: a thin wrapper around `Process.spawn` that blocks until the
20
23
  # command finishes
21
24
  #
22
25
  # Features:
26
+ #
23
27
  # * Supports executing commands via a shell or directly.
24
28
  # * Captures stdout and stderr to buffers, files, or custom objects.
25
29
  # * Optionally enforces timeouts and terminates long-running commands.
@@ -27,7 +31,6 @@ require 'timeout'
27
31
  # options that were given, and success, failure, or timeout states.
28
32
  #
29
33
  # @api public
30
- #
31
34
  module ProcessExecuter
32
35
  # Run a command in a subprocess, wait for it to finish, then return the result
33
36
  #
@@ -70,7 +73,20 @@ module ProcessExecuter
70
73
  # @return [ProcessExecuter::Result] The result of the completed subprocess
71
74
  #
72
75
  def self.spawn_and_wait(*command, **options_hash)
73
- options = ProcessExecuter::Options.new(**options_hash)
76
+ options = ProcessExecuter.spawn_and_wait_options(options_hash)
77
+ spawn_and_wait_with_options(command, options)
78
+ end
79
+
80
+ # Run a command in a subprocess, wait for it to finish, then return the result
81
+ #
82
+ # @see ProcessExecuter.spawn_and_wait for full documentation
83
+ #
84
+ # @param command [Array<String>] The command to run
85
+ # @param options [ProcessExecuter::Options::SpawnAndWaitOptions] The options to use when running the command
86
+ #
87
+ # @return [ProcessExecuter::Result] The result of the completed subprocess
88
+ # @api private
89
+ def self.spawn_and_wait_with_options(command, options)
74
90
  pid = Process.spawn(*command, **options.spawn_options)
75
91
  wait_for_process(pid, command, options)
76
92
  end
@@ -83,14 +99,11 @@ module ProcessExecuter
83
99
  # which can be accessed via the Result object in `result.options.out`. The
84
100
  # same applies to `err`.
85
101
  #
86
- # 2. If `merge` is set to `true`, stdout and stderr are captured to the same
87
- # buffer.
88
- #
89
- # 3. `out` and `err` are automatically wrapped in a
102
+ # 2. `out` and `err` are automatically wrapped in a
90
103
  # `ProcessExecuter::MonitoredPipe` object so that any object that implements
91
104
  # `#write` (or an Array of such objects) can be given for `out` and `err`.
92
105
  #
93
- # 4. Raises one of the following errors unless `raise_errors` is explicitly set
106
+ # 3. Raises one of the following errors unless `raise_errors` is explicitly set
94
107
  # to `false`:
95
108
  #
96
109
  # * `ProcessExecuter::FailedError` if the command returns a non-zero
@@ -101,10 +114,10 @@ module ProcessExecuter
101
114
  #
102
115
  # If `raise_errors` is false, the returned Result object will contain the error.
103
116
  #
104
- # 5. Raises a `ProcessExecuter::ProcessIOError` if an exception is raised
117
+ # 4. Raises a `ProcessExecuter::ProcessIOError` if an exception is raised
105
118
  # while collecting subprocess output. This can not be turned off.
106
119
  #
107
- # 6. If a `logger` is provided, it will be used to log:
120
+ # 5. If a `logger` is provided, it will be used to log:
108
121
  #
109
122
  # * The command that was executed and its status to `info` level
110
123
  # * The stdout and stderr output to `debug` level
@@ -199,7 +212,7 @@ module ProcessExecuter
199
212
  #
200
213
  # @example Capture stdout and stderr into a single buffer
201
214
  # command = ['echo "stdout" && echo "stderr" 1>&2']
202
- # result = ProcessExecuter.run(*command, merge: true)
215
+ # result = ProcessExecuter.run(*command, [out:, err:]: StringIO.new)
203
216
  # result.stdout #=> "stdout\nstderr\n"
204
217
  # result.stderr #=> "stdout\nstderr\n"
205
218
  # result.stdout.object_id == result.stderr.object_id #=> true
@@ -223,18 +236,16 @@ module ProcessExecuter
223
236
  # # stderr is still captured to a StringIO buffer internally
224
237
  # result.stderr #=> "stderr\n"
225
238
  #
226
- # @example Capture to multiple writers (e.g. files, buffers, STDOUT, etc.)
239
+ # @example Capture to multiple destinations (e.g. files, buffers, STDOUT, etc.)
227
240
  # # Same technique can be used for stderr
228
241
  # out_buffer = StringIO.new
229
242
  # out_file = File.open('stdout.txt', 'w')
230
243
  # command = ['echo "stdout" && echo "stderr" 1>&2']
231
- # result = ProcessExecuter.run(*command, out: [out_buffer, out_file])
244
+ # result = ProcessExecuter.run(*command, out: [:tee, out_buffer, out_file])
232
245
  # # You must manage closing resources you create yourself
233
246
  # out_file.close
234
247
  # out_buffer.string #=> "stdout\n"
235
248
  # File.read('stdout.txt') #=> "stdout\n"
236
- # # Since one of the out writers has a #string method, Result#stdout will
237
- # # return the string from that writer
238
249
  # result.stdout #=> "stdout\n"
239
250
  #
240
251
  # @param command [Array<String>] The command to run
@@ -252,7 +263,6 @@ module ProcessExecuter
252
263
  # Otherwise, the command is run bypassing the shell. When bypassing the shell, shell expansions
253
264
  # and redirections are not supported.
254
265
  #
255
- # @param logger [Logger] The logger to use
256
266
  # @param options_hash [Hash] Additional options
257
267
  # @option options_hash [Numeric] :timeout_after The maximum seconds to wait for the
258
268
  # command to complete
@@ -265,7 +275,6 @@ module ProcessExecuter
265
275
  #
266
276
  # @option options_hash [#write] :out (nil) The object to write stdout to
267
277
  # @option options_hash [#write] :err (nil) The object to write stderr to
268
- # @option options_hash [Boolean] :merge (false) If true, stdout and stderr are written to the same capture buffer
269
278
  # @option options_hash [Boolean] :raise_errors (true) Raise an exception if the command fails
270
279
  # @option options_hash [Boolean] :unsetenv_others (false) If true, unset all environment variables before
271
280
  # applying the new ones
@@ -276,6 +285,7 @@ module ProcessExecuter
276
285
  # @option options_hash [Integer] :umask (nil) Set the umask (see File.umask)
277
286
  # @option options_hash [Boolean] :close_others (false) If true, close non-standard file descriptors
278
287
  # @option options_hash [String] :chdir (nil) The directory to run the command in
288
+ # @option options_hash [Logger] :logger The logger to use
279
289
  #
280
290
  # @raise [ProcessExecuter::FailedError] if the command returned a non-zero exit status
281
291
  # @raise [ProcessExecuter::SignaledError] if the command exited because of an unhandled signal
@@ -284,8 +294,23 @@ module ProcessExecuter
284
294
  #
285
295
  # @return [ProcessExecuter::Result] The result of the completed subprocess
286
296
  #
287
- def self.run(*command, logger: Logger.new(nil), **options_hash)
288
- ProcessExecuter::Runner.new(logger).call(*command, **options_hash)
297
+ def self.run(*command, **options_hash)
298
+ options = ProcessExecuter.run_options(options_hash)
299
+ run_with_options(command, options)
300
+ end
301
+
302
+ # Run a command with the given options
303
+ #
304
+ # @see ProcessExecuter.run for full documentation
305
+ #
306
+ # @param command [Array<String>] The command to run
307
+ # @param options [ProcessExecuter::Options::RunOptions] The options to use when running the command
308
+ #
309
+ # @return [ProcessExecuter::Result] The result of the completed subprocess
310
+ #
311
+ # @api private
312
+ def self.run_with_options(command, options)
313
+ ProcessExecuter::Runner.new.call(command, options)
289
314
  end
290
315
 
291
316
  # Wait for process to terminate
@@ -328,4 +353,88 @@ module ProcessExecuter
328
353
 
329
354
  [process_status, timed_out]
330
355
  end
356
+
357
+ # Convert a hash to a SpawnOptions object
358
+ #
359
+ # @example
360
+ # options_hash = { out: $stdout }
361
+ # options = ProcessExecuter.spawn_options(options_hash) # =>
362
+ # #<ProcessExecuter::Options::SpawnOptions:0x00007f8f9b0b3d20 out: $stdout>
363
+ # ProcessExecuter.spawn_options(options) # =>
364
+ # #<ProcessExecuter::Options::SpawnOptions:0x00007f8f9b0b3d20 out: $stdout>
365
+ #
366
+ # @param obj [Hash, SpawnOptions] the object to be converted
367
+ #
368
+ # @return [SpawnOptions]
369
+ #
370
+ # @raise [ArgumentError] if obj is not a Hash or SpawnOptions
371
+ #
372
+ # @api public
373
+ #
374
+ def self.spawn_options(obj)
375
+ case obj
376
+ when ProcessExecuter::Options::SpawnOptions
377
+ obj
378
+ when Hash
379
+ ProcessExecuter::Options::SpawnOptions.new(**obj)
380
+ else
381
+ raise ArgumentError, "Expected a Hash or ProcessExecuter::Options::SpawnOptions but got a #{obj.class}"
382
+ end
383
+ end
384
+
385
+ # Convert a hash to a SpawnAndWaitOptions object
386
+ #
387
+ # @example
388
+ # options_hash = { out: $stdout }
389
+ # options = ProcessExecuter.spawn_and_wait_options(options_hash) # =>
390
+ # #<ProcessExecuter::Options::SpawnAndWaitOptions:0x00007f8f9b0b3d20 out: $stdout>
391
+ # ProcessExecuter.spawn_and_wait_options(options) # =>
392
+ # #<ProcessExecuter::Options::SpawnAndWaitOptions:0x00007f8f9b0b3d20 out: $stdout>
393
+ #
394
+ # @param obj [Hash, SpawnAndWaitOptions] the object to be converted
395
+ #
396
+ # @return [SpawnAndWaitOptions]
397
+ #
398
+ # @raise [ArgumentError] if obj is not a Hash or SpawnOptions
399
+ #
400
+ # @api public
401
+ #
402
+ def self.spawn_and_wait_options(obj)
403
+ case obj
404
+ when ProcessExecuter::Options::SpawnAndWaitOptions
405
+ obj
406
+ when Hash
407
+ ProcessExecuter::Options::SpawnAndWaitOptions.new(**obj)
408
+ else
409
+ raise ArgumentError, "Expected a Hash or ProcessExecuter::Options::SpawnAndWaitOptions but got a #{obj.class}"
410
+ end
411
+ end
412
+
413
+ # Convert a hash to a RunOptions object
414
+ #
415
+ # @example
416
+ # options_hash = { out: $stdout }
417
+ # options = ProcessExecuter.run_options(options_hash) # =>
418
+ # #<ProcessExecuter::Options::RunOptions:0x00007f8f9b0b3d20 out: $stdout>
419
+ # ProcessExecuter.run_options(options) # =>
420
+ # #<ProcessExecuter::Options::RunOptions:0x00007f8f9b0b3d20 out: $stdout>
421
+ #
422
+ # @param obj [Hash, RunOptions] the object to be converted
423
+ #
424
+ # @return [RunOptions]
425
+ #
426
+ # @raise [ArgumentError] if obj is not a Hash or SpawnOptions
427
+ #
428
+ # @api public
429
+ #
430
+ def self.run_options(obj)
431
+ case obj
432
+ when ProcessExecuter::Options::RunOptions
433
+ obj
434
+ when Hash
435
+ ProcessExecuter::Options::RunOptions.new(**obj)
436
+ else
437
+ raise ArgumentError, "Expected a Hash or ProcessExecuter::Options::RunOptions but got a #{obj.class}"
438
+ end
439
+ end
331
440
  end