process_executer 3.2.3 → 4.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.commitlintrc.yml +27 -6
  3. data/.release-please-manifest.json +1 -1
  4. data/CHANGELOG.md +49 -0
  5. data/README.md +177 -134
  6. data/lib/process_executer/commands/run.rb +124 -0
  7. data/lib/process_executer/commands/run_with_capture.rb +148 -0
  8. data/lib/process_executer/commands/spawn_with_timeout.rb +163 -0
  9. data/lib/process_executer/commands.rb +11 -0
  10. data/lib/process_executer/destinations/child_redirection.rb +5 -4
  11. data/lib/process_executer/destinations/close.rb +5 -4
  12. data/lib/process_executer/destinations/destination_base.rb +73 -0
  13. data/lib/process_executer/destinations/file_descriptor.rb +10 -6
  14. data/lib/process_executer/destinations/file_path.rb +12 -6
  15. data/lib/process_executer/destinations/file_path_mode.rb +10 -6
  16. data/lib/process_executer/destinations/file_path_mode_perms.rb +12 -5
  17. data/lib/process_executer/destinations/io.rb +10 -5
  18. data/lib/process_executer/destinations/monitored_pipe.rb +10 -5
  19. data/lib/process_executer/destinations/stderr.rb +8 -4
  20. data/lib/process_executer/destinations/stdout.rb +8 -4
  21. data/lib/process_executer/destinations/tee.rb +24 -17
  22. data/lib/process_executer/destinations/writer.rb +12 -7
  23. data/lib/process_executer/destinations.rb +32 -17
  24. data/lib/process_executer/errors.rb +50 -26
  25. data/lib/process_executer/monitored_pipe.rb +128 -59
  26. data/lib/process_executer/options/base.rb +118 -82
  27. data/lib/process_executer/options/option_definition.rb +5 -1
  28. data/lib/process_executer/options/run_options.rb +13 -12
  29. data/lib/process_executer/options/run_with_capture_options.rb +156 -0
  30. data/lib/process_executer/options/spawn_options.rb +31 -30
  31. data/lib/process_executer/options/{spawn_and_wait_options.rb → spawn_with_timeout_options.rb} +11 -7
  32. data/lib/process_executer/options.rb +3 -1
  33. data/lib/process_executer/result.rb +35 -77
  34. data/lib/process_executer/result_with_capture.rb +62 -0
  35. data/lib/process_executer/version.rb +2 -1
  36. data/lib/process_executer.rb +384 -346
  37. data/process_executer.gemspec +11 -2
  38. data/release-please-config.json +16 -2
  39. metadata +18 -8
  40. data/lib/process_executer/destination_base.rb +0 -83
  41. data/lib/process_executer/runner.rb +0 -144
@@ -1,441 +1,479 @@
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'
8
- require 'process_executer/errors'
9
- require 'process_executer/monitored_pipe'
10
- require 'process_executer/options'
11
- require 'process_executer/result'
12
- require 'process_executer/runner'
13
-
14
- # The `ProcessExecuter` module provides methods to execute subprocess commands
15
- # with enhanced features such as output capture, timeout handling, and custom
16
- # environment variables.
3
+ # The {ProcessExecuter} module provides extended versions of
4
+ # [Process.spawn](https://docs.ruby-lang.org/en/3.4/Process.html#method-c-spawn) that
5
+ # block while the command is executing. These methods provide enhanced features such
6
+ # as timeout handling, more flexible redirection options, logging, error raising, and
7
+ # output capturing.
17
8
  #
18
- # Methods:
9
+ # The interface of these methods is the same as the standard library
10
+ # [Process.spawn](https://docs.ruby-lang.org/en/3.4/Process.html#method-c-spawn)
11
+ # method, but with additional options and features.
19
12
  #
20
- # * {run}: Executes a command and returns the result which includes the process
21
- # status and output
22
- # * {spawn_and_wait}: a thin wrapper around `Process.spawn` that blocks until the
23
- # command finishes
13
+ # These methods are:
24
14
  #
25
- # Features:
15
+ # * {spawn_with_timeout}: Extends
16
+ # [Process.spawn](https://docs.ruby-lang.org/en/3.4/Process.html#method-c-spawn) to
17
+ # run a command and wait (with timeout) for it to finish
18
+ # * {run}: Extends {ProcessExecuter.spawn_with_timeout}, adding more flexible
19
+ # redirection and other options
20
+ # * {run_with_capture}: Extends {ProcessExecuter.run}, automatically captures stdout and stderr
26
21
  #
27
- # * Supports executing commands via a shell or directly.
28
- # * Captures stdout and stderr to buffers, files, or custom objects.
29
- # * Optionally enforces timeouts and terminates long-running commands.
30
- # * Provides detailed status information, including the command that was run, the
31
- # options that were given, and success, failure, or timeout states.
22
+ # See the {ProcessExecuter::Error} class for the error architecture for this module.
32
23
  #
33
24
  # @api public
34
25
  module ProcessExecuter
35
- # Run a command in a subprocess, wait for it to finish, then return the result
26
+ # Extends `Process.spawn` to run command and wait (with timeout) for it to finish
27
+ #
28
+ # Accepts all [Process.spawn execution
29
+ # options](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Execution+Options)
30
+ # and the additional option `timeout_after`:
31
+ #
32
+ # * `timeout_after: <Numeric, nil>`: the amount of time (in seconds) to wait before
33
+ # signaling the process with SIGKILL. 0 or nil means no timeout.
36
34
  #
37
- # This method is a thin wrapper around
38
- # [Process.spawn](https://docs.ruby-lang.org/en/3.3/Process.html#method-c-spawn)
39
- # and blocks until the command terminates.
35
+ # Returns a {Result} object. The {Result} class is a decorator for
36
+ # [Process::Status](https://docs.ruby-lang.org/en/3.4/Process/Status.html) that
37
+ # provides additional attributes about the command's status. This includes the
38
+ # {Result#command command} that was run, the {Result#options options} used to run
39
+ # it, {Result#elapsed_time elapsed_time} of the command, and whether the command
40
+ # {Result#timed_out? timed_out?}.
40
41
  #
41
- # A timeout may be specified with the `:timeout_after` option. The command will be
42
- # sent the SIGKILL signal if it does not terminate within the specified timeout.
42
+ # @overload spawn_with_timeout(*command, **options_hash)
43
43
  #
44
- # @example
45
- # result = ProcessExecuter.spawn_and_wait('echo hello')
44
+ # @param command [Array<String>] see [Process module, Argument `command_line` or
45
+ # `exe_path`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Argument+command_line)
46
+ # and [Process module, Argument
47
+ # `args`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Arguments+args)
48
+ #
49
+ # If the first value is a Hash, it is treated as the environment hash. See
50
+ # [Process module, Execution Environment](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Execution+Environment).
51
+ #
52
+ # @param options_hash [Hash] In addition to the options documented in [Process
53
+ # module, Execution
54
+ # Options](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Execution+Options),
55
+ # the following options are supported: `:timeout_after`
56
+ #
57
+ # @option options_hash [Numeric] :timeout_after the amount of time (in seconds)
58
+ # to wait before signaling the process with SIGKILL
59
+ #
60
+ # @overload spawn_with_timeout(*command, options)
61
+ #
62
+ # @param command [Array<String>] see [Process module, Argument `command_line` or
63
+ # `exe_path`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Argument+command_line)
64
+ # and [Process module, Argument
65
+ # `args`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Arguments+args)
66
+ #
67
+ # If the first value is a Hash, it is treated as the environment hash. See
68
+ # [Process module, Execution Environment](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Execution+Environment).
69
+ #
70
+ # @param options [ProcessExecuter::Options::SpawnWithTimeoutOptions]
71
+ #
72
+ # @example command line given as a single string
73
+ # result = ProcessExecuter.spawn_with_timeout('echo "3\n2\n1" | sort')
46
74
  # result.exited? # => true
47
75
  # result.success? # => true
76
+ # result.exitstatus # => 0
48
77
  # result.timed_out? # => false
49
78
  #
79
+ # @example command given as an exe_path and args
80
+ # result = ProcessExecuter.spawn_with_timeout('ping', '-c', '1', 'localhost')
81
+ #
50
82
  # @example with a timeout
51
- # result = ProcessExecuter.spawn_and_wait('sleep 10', timeout_after: 0.01)
83
+ # result = ProcessExecuter.spawn_with_timeout('sleep 10', timeout_after: 0.01)
52
84
  # result.exited? # => false
53
85
  # result.success? # => nil
54
86
  # result.signaled? # => true
55
87
  # result.termsig # => 9
56
88
  # result.timed_out? # => true
57
89
  #
58
- # @example capturing stdout to a string
90
+ # @example with a env hash
91
+ # env = { 'EXITSTATUS' => '1' }
92
+ # result = ProcessExecuter.spawn_with_timeout(env, 'exit $EXITSTATUS')
93
+ # result.success? # => false
94
+ # result.exitstatus # => 1
95
+ #
96
+ # @example capture stdout to a StringIO buffer
59
97
  # stdout_buffer = StringIO.new
60
98
  # stdout_pipe = ProcessExecuter::MonitoredPipe.new(stdout_buffer)
61
- # result = ProcessExecuter.spawn_and_wait('echo hello', out: stdout_pipe)
62
- # stdout_buffer.string # => "hello\n"
99
+ # begin
100
+ # result = ProcessExecuter.spawn_with_timeout('echo "3\n2\n1" | sort', out: stdout_pipe)
101
+ # stdout_buffer.string # => "1\n2\n3\n"
102
+ # ensure
103
+ # stdout_pipe.close
104
+ # end
105
+ #
106
+ # @raise [ProcessExecuter::ArgumentError] If the command or an option is not valid
63
107
  #
64
- # @see https://ruby-doc.org/core-3.1.2/Kernel.html#method-i-spawn Kernel.spawn
65
- # documentation for valid command and options
108
+ # Raised if an invalid option key or value is given, or both an options object
109
+ # and options_hash are given.
66
110
  #
67
- # @see ProcessExecuter::Options#initialize ProcessExecuter::Options#initialize for
68
- # options that may be specified
111
+ # @raise [ProcessExecuter::SpawnError] `Process.spawn` raised an error before the
112
+ # command was run
69
113
  #
70
- # @param command [Array<String>] The command to execute
71
- # @param options_hash [Hash] The options to use when executing the command
114
+ # Raised if the
115
+ # [Process.spawn](https://docs.ruby-lang.org/en/3.4/Process.html#method-c-spawn)
116
+ # method raises an error before the command is run.
72
117
  #
73
- # @return [ProcessExecuter::Result] The result of the completed subprocess
118
+ # @return [ProcessExecuter::Result]
74
119
  #
75
- def self.spawn_and_wait(*command, **options_hash)
76
- options = ProcessExecuter.spawn_and_wait_options(options_hash)
77
- spawn_and_wait_with_options(command, options)
120
+ # @api public
121
+ #
122
+ def self.spawn_with_timeout(*command, **options_hash)
123
+ command, options = command_and_options(Options::SpawnWithTimeoutOptions, command, options_hash)
124
+ ProcessExecuter::Commands::SpawnWithTimeout.new(command, options).call
78
125
  end
79
126
 
80
- # Run a command in a subprocess, wait for it to finish, then return the result
127
+ # Extends {spawn_with_timeout}, adding more flexible redirection and other options
81
128
  #
82
- # @see ProcessExecuter.spawn_and_wait for full documentation
129
+ # Accepts all [Process.spawn execution
130
+ # options](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Execution+Options),
131
+ # the additional options defined by {spawn_with_timeout}, and the additional
132
+ # options `raise_errors` and `logger`:
83
133
  #
84
- # @param command [Array<String>] The command to run
85
- # @param options [ProcessExecuter::Options::SpawnAndWaitOptions] The options to use when running the command
134
+ # * `raise_errors: <Boolean>` makes execution errors an exception if true (default
135
+ # is `true`)
136
+ # * `logger: <Logger>` logs the command and its result at `:info` level using the
137
+ # given logger (default is not to log)
86
138
  #
87
- # @return [ProcessExecuter::Result] The result of the completed subprocess
88
- # @api private
89
- def self.spawn_and_wait_with_options(command, options)
90
- begin
91
- pid = Process.spawn(*command, **options.spawn_options)
92
- rescue StandardError => e
93
- raise ProcessExecuter::SpawnError, "Failed to spawn process: #{e.message}"
94
- end
95
- wait_for_process(pid, command, options)
96
- end
97
-
98
- # Execute the given command as a subprocess blocking until it finishes
139
+ # Internally, this method wraps stdout and stderr redirection options in a
140
+ # {MonitoredPipe}, enabling more flexible output handling. It allows any object
141
+ # that responds to `#write` to be used as a destination and supports multiple
142
+ # destinations using the form `[:tee, destination, ...]`.
99
143
  #
100
- # Works just like {ProcessExecuter.spawn}, but does the following in addition:
144
+ # When the command exits with a non-zero exit status or does not exit normally, one
145
+ # of the following errors will be raised unless the option `raise_errors: false` is
146
+ # explicitly given:
101
147
  #
102
- # 1. If nothing is specified for `out`, stdout is captured to a `StringIO` object
103
- # which can be accessed via the Result object in `result.options.out`. The
104
- # same applies to `err`.
148
+ # * {ProcessExecuter::FailedError} if the command returns a non-zero exitstatus
149
+ # * {ProcessExecuter::SignaledError} if the command exits because of an unhandled
150
+ # signal
151
+ # * {ProcessExecuter::TimeoutError} if the command times out
105
152
  #
106
- # 2. `out` and `err` are automatically wrapped in a
107
- # `ProcessExecuter::MonitoredPipe` object so that any object that implements
108
- # `#write` (or an Array of such objects) can be given for `out` and `err`.
153
+ # These errors all have a {CommandError#result result} attribute that contains the
154
+ # {ProcessExecuter::Result} object for this command.
109
155
  #
110
- # 3. Raises one of the following errors unless `raise_errors` is explicitly set
111
- # to `false`:
156
+ # If `raise_errors: false` is given and there was an error, the returned
157
+ # {ProcessExecuter::Result} object indicates what the error is via its
158
+ # [success?](https://docs.ruby-lang.org/en/3.4/Process/Status.html#method-i-success-3F),
159
+ # [signaled?](https://docs.ruby-lang.org/en/3.4/Process/Status.html#method-i-signaled-3F),
160
+ # or {Result#timed_out? timed_out?} attributes.
112
161
  #
113
- # * `ProcessExecuter::FailedError` if the command returns a non-zero
114
- # exitstatus
115
- # * `ProcessExecuter::SignaledError` if the command exits because of
116
- # an unhandled signal
117
- # * `ProcessExecuter::TimeoutError` if the command times out
162
+ # A {ProcessExecuter::ProcessIOError} is raised if an exception occurs while
163
+ # collecting subprocess output.
118
164
  #
119
- # If `raise_errors` is false, the returned Result object will contain the error.
165
+ # Giving the option `raise_errors: false` will not suppress
166
+ # {ProcessExecuter::ProcessIOError}, {ProcessExecuter::SpawnError}, or
167
+ # {ProcessExecuter::ArgumentError} errors.
120
168
  #
121
- # 4. Raises a `ProcessExecuter::ProcessIOError` if an exception is raised
122
- # while collecting subprocess output. This can not be turned off.
169
+ # @example capture stdout to a StringIO buffer
170
+ # out_buffer = StringIO.new
171
+ # result = ProcessExecuter.run('echo HELLO', out: out_buffer)
172
+ # out_buffer.string #=> "HELLO\n"
173
+ #
174
+ # @example with :raise_errors set to true
175
+ # begin
176
+ # result = ProcessExecuter.run('exit 1', raise_errors: true)
177
+ # rescue ProcessExecuter::FailedError => e
178
+ # e.result.exitstatus #=> 1
179
+ # end
180
+ #
181
+ # @example with :raise_errors set to false
182
+ # result = ProcessExecuter.run('exit 1', raise_errors: false)
183
+ # result.exitstatus #=> 1
123
184
  #
124
- # 5. If a `logger` is provided, it will be used to log:
185
+ # @example with a logger
186
+ # logger_buffer = StringIO.new
187
+ # logger = Logger.new(logger_buffer, level: :info)
188
+ # result = ProcessExecuter.run('echo HELLO', logger: logger)
189
+ # logger_buffer.string #=> "INFO -- : PID 5555: [\"echo HELLO\"] exited with status pid 5555 exit 0\n"
125
190
  #
126
- # * The command that was executed and its status to `info` level
127
- # * The stdout and stderr output to `debug` level
191
+ # @overload run(*command, **options_hash)
128
192
  #
129
- # By default, Logger.new(nil) is used for the logger.
193
+ # @param command [Array<String>] see [Process module, Argument `command_line` or
194
+ # `exe_path`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Argument+command_line)
195
+ # and [Process module, Argument
196
+ # `args`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Arguments+args)
130
197
  #
131
- # This method takes two forms:
198
+ # If the first value is a Hash, it is treated as the environment hash. See
199
+ # [Process module, Execution Environment](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Execution+Environment).
132
200
  #
133
- # 1. The command is executed via a shell when the command is given as a single
134
- # string:
201
+ # @param [Hash] options_hash in addition to the options supported by
202
+ # {spawn_with_timeout}, the following options may be given: `:raise_errors` and
203
+ # `:logger`
135
204
  #
136
- # `ProcessExecuter.run([env, ] command_line, options = {}) ->` {ProcessExecuter::Result}
205
+ # @option options_hash [Boolean] :raise_errors if true, an error will be raised
206
+ # if the command fails
137
207
  #
138
- # 2. The command is executed directly (bypassing the shell) when the command and it
139
- # arguments are given as an array of strings:
208
+ # @option options_hash [Logger] :logger a logger to use for logging the command
209
+ # and its result at the info level
140
210
  #
141
- # `ProcessExecuter.run([env, ] exe_path, *args, options = {}) ->` {ProcessExecuter::Result}
211
+ # @option options_hash [Numeric] :timeout_after the amount of time (in seconds)
212
+ # to wait before signaling the process with SIGKILL
142
213
  #
143
- # Optional argument `env` is a hash that affects ENV for the new process; see
144
- # [Execution
145
- # Environment](https://docs.ruby-lang.org/en/3.3/Process.html#module-Process-label-Execution+Environment).
214
+ # @overload run(*command, options)
146
215
  #
147
- # Argument `options` is a hash of options for the new process. See the options listed below.
216
+ # @param command [Array<String>] see [Process module, Argument `command_line` or
217
+ # `exe_path`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Argument+command_line)
218
+ # and [Process module, Argument
219
+ # `args`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Arguments+args)
148
220
  #
149
- # @example Run a command given as a single string (uses shell)
150
- # # The command must be properly shell escaped when passed as a single string.
151
- # command = 'echo "stdout: `pwd`" && echo "stderr: $HOME" 1>&2'
152
- # result = ProcessExecuter.run(command)
153
- # result.success? #=> true
154
- # result.stdout #=> "stdout: /Users/james/projects/main-branch/process_executer\n"
155
- # result.stderr #=> "stderr: /Users/james\n"
221
+ # If the first value is a Hash, it is treated as the environment hash. See
222
+ # [Process module, Execution Environment](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Execution+Environment).
156
223
  #
157
- # @example Run a command given as an array of strings (does not use shell)
158
- # # The command and its args must be provided as separate strings in the array.
159
- # # Shell expansions and redirections are not supported.
160
- # command = ['git', 'clone', 'https://github.com/main-branch/process_executer']
161
- # result = ProcessExecuter.run(*command)
162
- # result.success? #=> true
163
- # result.stdout #=> ""
164
- # result.stderr #=> "Cloning into 'process_executer'...\n"
224
+ # @param options [ProcessExecuter::Options::RunOptions]
165
225
  #
166
- # @example Run a command with a timeout
167
- # command = ['sleep', '1']
168
- # result = ProcessExecuter.run(*command, timeout_after: 0.01)
169
- # #=> raises ProcessExecuter::TimeoutError which contains the command result
226
+ # @raise [ProcessExecuter::ArgumentError] If the command or an option is not valid
170
227
  #
171
- # @example Run a command which fails
172
- # command = ['exit 1']
173
- # result = ProcessExecuter.run(*command)
174
- # #=> raises ProcessExecuter::FailedError which contains the command result
228
+ # Raised if an invalid option key or value is given, or both an options object
229
+ # and options_hash are given.
175
230
  #
176
- # @example Run a command which exits due to an unhandled signal
177
- # command = ['kill -9 $$']
178
- # result = ProcessExecuter.run(*command)
179
- # #=> raises ProcessExecuter::SignaledError which contains the command result
231
+ # @raise [ProcessExecuter::SpawnError] `Process.spawn` raised an error before the
232
+ # command was run
180
233
  #
181
- # @example Do not raise an error when the command fails
182
- # command = ['echo "Some error" 1>&2 && exit 1']
183
- # result = ProcessExecuter.run(*command, raise_errors: false)
184
- # result.success? #=> false
185
- # result.exitstatus #=> 1
186
- # result.stdout #=> ""
187
- # result.stderr #=> "Some error\n"
188
- #
189
- # @example Set environment variables
190
- # env = { 'FOO' => 'foo', 'BAR' => 'bar' }
191
- # command = 'echo "$FOO$BAR"'
192
- # result = ProcessExecuter.run(env, *command)
193
- # result.stdout #=> "foobar\n"
194
- #
195
- # @example Set environment variables when using a command array
196
- # env = { 'FOO' => 'foo', 'BAR' => 'bar' }
197
- # command = ['ruby', '-e', 'puts ENV["FOO"] + ENV["BAR"]']
198
- # result = ProcessExecuter.run(env, *command)
199
- # result.stdout #=> "foobar\n"
200
- #
201
- # @example Unset environment variables
202
- # env = { 'FOO' => nil } # setting to nil unsets the variable in the environment
203
- # command = ['echo "FOO: $FOO"']
204
- # result = ProcessExecuter.run(env, *command)
205
- # result.stdout #=> "FOO: \n"
206
- #
207
- # @example Reset existing environment variables and add new ones
208
- # env = { 'PATH' => '/bin' }
209
- # result = ProcessExecuter.run(env, 'echo "Home: $HOME" && echo "Path: $PATH"', unsetenv_others: true)
210
- # result.stdout #=> "Home: \n/Path: /bin\n"
211
- #
212
- # @example Run command in a different directory
213
- # command = ['pwd']
214
- # result = ProcessExecuter.run(*command, chdir: '/tmp')
215
- # result.stdout #=> "/tmp\n"
216
- #
217
- # @example Capture stdout and stderr into a single buffer
218
- # command = ['echo "stdout" && echo "stderr" 1>&2']
219
- # result = ProcessExecuter.run(*command, [out:, err:]: StringIO.new)
220
- # result.stdout #=> "stdout\nstderr\n"
221
- # result.stderr #=> "stdout\nstderr\n"
222
- # result.stdout.object_id == result.stderr.object_id #=> true
223
- #
224
- # @example Capture to an explicit buffer
225
- # out = StringIO.new
226
- # err = StringIO.new
227
- # command = ['echo "stdout" && echo "stderr" 1>&2']
228
- # result = ProcessExecuter.run(*command, out: out, err: err)
229
- # out.string #=> "stdout\n"
230
- # err.string #=> "stderr\n"
231
- #
232
- # @example Capture to a file
233
- # # Same technique can be used for stderr
234
- # out = File.open('stdout.txt', 'w')
235
- # err = StringIO.new
236
- # command = ['echo "stdout" && echo "stderr" 1>&2']
237
- # result = ProcessExecuter.run(*command, out: out, err: err)
238
- # out.close
239
- # File.read('stdout.txt') #=> "stdout\n"
240
- # # stderr is still captured to a StringIO buffer internally
241
- # result.stderr #=> "stderr\n"
242
- #
243
- # @example Capture to multiple destinations (e.g. files, buffers, STDOUT, etc.)
244
- # # Same technique can be used for stderr
245
- # out_buffer = StringIO.new
246
- # out_file = File.open('stdout.txt', 'w')
247
- # command = ['echo "stdout" && echo "stderr" 1>&2']
248
- # result = ProcessExecuter.run(*command, out: [:tee, out_buffer, out_file])
249
- # # You must manage closing resources you create yourself
250
- # out_file.close
251
- # out_buffer.string #=> "stdout\n"
252
- # File.read('stdout.txt') #=> "stdout\n"
253
- # result.stdout #=> "stdout\n"
254
- #
255
- # @param command [Array<String>] The command to run
256
- #
257
- # If the first element of command is a Hash, it is added to the ENV of
258
- # the new process. See [Execution Environment](https://ruby-doc.org/3.3.6/Process.html#module-Process-label-Execution+Environment)
259
- # for more details. The env hash is then removed from the command array.
260
- #
261
- # If the first and only (remaining) command element is a string, it is passed to
262
- # a subshell if it begins with a shell reserved word, contains special built-ins,
263
- # or includes shell metacharacters.
264
- #
265
- # Care must be taken to properly escape shell metacharacters in the command string.
266
- #
267
- # Otherwise, the command is run bypassing the shell. When bypassing the shell, shell expansions
268
- # and redirections are not supported.
269
- #
270
- # @param options_hash [Hash] Additional options
271
- # @option options_hash [Numeric] :timeout_after The maximum seconds to wait for the
272
- # command to complete
273
- #
274
- # If zero or nil, the command will not time out. If the command
275
- # times out, it is killed via a SIGKILL signal. A {ProcessExecuter::TimeoutError}
276
- # will be raised if the `:raise_errors` option is true.
277
- #
278
- # If the command does not exit when receiving the SIGKILL signal, this method may hang indefinitely.
279
- #
280
- # @option options_hash [#write] :out (nil) The object to write stdout to
281
- # @option options_hash [#write] :err (nil) The object to write stderr to
282
- # @option options_hash [Boolean] :raise_errors (true) Raise an exception if the command fails
283
- # @option options_hash [Boolean] :unsetenv_others (false) If true, unset all environment variables before
284
- # applying the new ones
285
- # @option options_hash [true, Integer, nil] :pgroup (nil) true or 0: new process group; non-zero: join
286
- # the group, nil: existing group
287
- # @option options_hash [Boolean] :new_pgroup (nil) Create a new process group (Windows only)
288
- # @option options_hash [Integer] :rlimit_resource_name (nil) Set resource limits (see Process.setrlimit)
289
- # @option options_hash [Integer] :umask (nil) Set the umask (see File.umask)
290
- # @option options_hash [Boolean] :close_others (false) If true, close non-standard file descriptors
291
- # @option options_hash [String] :chdir (nil) The directory to run the command in
292
- # @option options_hash [Logger] :logger The logger to use
293
- #
294
- # @raise [ProcessExecuter::Error] if the command could not be executed or failed
295
- #
296
- # @return [ProcessExecuter::Result] The result of the completed subprocess
234
+ # Raised if the
235
+ # [Process.spawn](https://docs.ruby-lang.org/en/3.4/Process.html#method-c-spawn)
236
+ # method raises an error before the command is run.
237
+ #
238
+ # @raise [ProcessExecuter::FailedError] If the command ran and failed
239
+ #
240
+ # @raise [ProcessExecuter::SignaledError] If the command ran and terminated due to
241
+ # an unhandled signal
242
+ #
243
+ # @raise [ProcessExecuter::TimeoutError] If the command timed out
244
+ #
245
+ # @raise [ProcessExecuter::ProcessIOError] If there was an exception while
246
+ # collecting subprocess output
247
+ #
248
+ # @return [ProcessExecuter::Result]
249
+ #
250
+ # @api public
297
251
  #
298
252
  def self.run(*command, **options_hash)
299
- options = ProcessExecuter.run_options(options_hash)
300
- run_with_options(command, options)
253
+ command, options = command_and_options(Options::RunOptions, command, options_hash)
254
+ ProcessExecuter::Commands::Run.new(command, options).call
301
255
  end
302
256
 
303
- # Run a command with the given options
257
+ # Extends {run}, automatically capturing stdout and stderr
258
+ #
259
+ # Accepts all [Process.spawn execution
260
+ # options](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Execution+Options),
261
+ # the additional options defined by {spawn_with_timeout} and {run}, and the
262
+ # additional options `merge_output`, `encoding`, `stdout_encoding`, and
263
+ # `stderr_encoding`:
264
+ #
265
+ # * `merge_output: <Boolean>` if true merges stdout and stderr into a single
266
+ # capture buffer (default is false)
267
+ # * `encoding: <Encoding>` sets the encoding for both stdout and stderr captures
268
+ # (default is `Encoding::UTF_8`)
269
+ # * `stdout_encoding: <Encoding>` sets the encoding for the stdout capture and, if
270
+ # not nil, overrides the `encoding` option for stdout (default is nil)
271
+ # * `stderr_encoding: <Encoding>` sets the encoding for the stderr capture and, if
272
+ # not nil, overrides the `encoding` option for stderr (default is nil)
304
273
  #
305
- # @see ProcessExecuter.run for full documentation
274
+ # The captured output is accessed in the returned object's `#stdout` and `#stderr`
275
+ # methods. Merged output (if the `merged_output: true` option is given) is accessed
276
+ # in the `#stdout` method.
306
277
  #
307
- # @param command [Array<String>] The command to run
308
- # @param options [ProcessExecuter::Options::RunOptions] The options to use when running the command
278
+ # stdout and stderr redirection destinations may be given by the user (e.g. `out:
279
+ # <destination>` or `err: <destination>`). These redirections will receive the
280
+ # output in addition to the internal capture.
309
281
  #
310
- # @return [ProcessExecuter::Result] The result of the completed subprocess
282
+ # Unless told otherwise, the internally captured output is assumed to be in UTF-8
283
+ # encoding. This assumption can be changed with the `encoding`,
284
+ # `stdout_encoding`, or `stderr_encoding` options. These options accept any
285
+ # encoding objects returned by `Encoding.list` or their String equivalent given by
286
+ # `#to_s`.
311
287
  #
312
- # @api private
313
- def self.run_with_options(command, options)
314
- ProcessExecuter::Runner.new.call(command, options)
315
- end
316
-
317
- # Wait for process to terminate
288
+ # The bytes captured are not transcoded. They are interpreted as being in the
289
+ # specified encoding. The user will have to check the validity of the
290
+ # encoding by calling `#valid_encoding?` on the captured output (e.g.,
291
+ # `result.stdout.valid_encoding?`).
318
292
  #
319
- # If a `:timeout_after` is specified in options, terminate the process after the
320
- # specified number of seconds.
293
+ # A `ProcessExecuter::ArgumentError` will be raised if both an options object and
294
+ # an options_hash are given.
321
295
  #
322
- # @param pid [Integer] the process ID
323
- # @param options [ProcessExecuter::Options] the options used
296
+ # @example capture stdout and stderr
297
+ # result =
298
+ # ProcessExecuter.run_with_capture('echo HELLO; echo ERROR >&2')
299
+ # result.stdout #=> "HELLO\n" result.stderr #=> "ERROR\n"
324
300
  #
325
- # @return [ProcessExecuter::Result] The result of the completed subprocess
301
+ # @example merge stdout and stderr
302
+ # result = ProcessExecuter.run_with_capture('echo HELLO; echo ERROR >&2', merge_output: true)
303
+ # # order of output is not guaranteed
304
+ # result.stdout #=> "HELLO\nERROR\n" result.stderr #=> ""
326
305
  #
327
- # @api private
306
+ # @example default encoding
307
+ # result = ProcessExecuter.run_with_capture('echo HELLO')
308
+ # result.stdout #=> "HELLO\n"
309
+ # result.stdout.encoding #=> #<Encoding:UTF-8>
310
+ # result.stdout.valid_encoding? #=> true
328
311
  #
329
- private_class_method def self.wait_for_process(pid, command, options)
330
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
331
- process_status, timed_out = wait_for_process_raw(pid, options.timeout_after)
332
- elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
333
- ProcessExecuter::Result.new(process_status, command:, options:, timed_out:, elapsed_time:)
334
- end
335
-
336
- # Wait for a process to terminate returning the status and timed out flag
312
+ # @example custom encoding
313
+ # result = ProcessExecuter.run_with_capture('echo HELLO', encoding: Encoding::ISO_8859_1)
314
+ # result.stdout #=> "HELLO\n"
315
+ # result.stdout.encoding #=> #<Encoding:ISO-8859-1>
316
+ # result.stdout.valid_encoding? #=> true
337
317
  #
338
- # @param pid [Integer] the process ID
339
- # @param timeout_after [Numeric, nil] the number of seconds to wait for the process to terminate
340
- # @return [Array<Process::Status, Boolean>] an array containing the process status and a boolean
341
- # indicating whether the process timed out
342
- # @api private
343
- private_class_method def self.wait_for_process_raw(pid, timeout_after)
344
- timed_out = false
345
-
346
- process_status =
347
- begin
348
- Timeout.timeout(timeout_after) { Process.wait2(pid).last }
349
- rescue Timeout::Error
350
- Process.kill('KILL', pid)
351
- timed_out = true
352
- Process.wait2(pid).last
353
- end
354
-
355
- [process_status, timed_out]
356
- end
357
-
358
- # Convert a hash to a SpawnOptions object
318
+ # @example custom encoding with invalid bytes
319
+ # File.binwrite('output.txt', "\xFF\xFE") # little-endian BOM marker is not valid UTF-8
320
+ # result = ProcessExecuter.run_with_capture('cat output.txt')
321
+ # result.stdout #=> "\xFF\xFE"
322
+ # result.stdout.encoding #=> #<Encoding:UTF-8>
323
+ # result.stdout.valid_encoding? #=> false
359
324
  #
360
- # @example
361
- # options_hash = { out: $stdout }
362
- # options = ProcessExecuter.spawn_options(options_hash) # =>
363
- # #<ProcessExecuter::Options::SpawnOptions:0x00007f8f9b0b3d20 out: $stdout>
364
- # ProcessExecuter.spawn_options(options) # =>
365
- # #<ProcessExecuter::Options::SpawnOptions:0x00007f8f9b0b3d20 out: $stdout>
325
+ # @overload run_with_capture(*command, **options_hash)
366
326
  #
367
- # @param obj [Hash, SpawnOptions] the object to be converted
327
+ # @param command [Array<String>] see [Process module, Argument `command_line` or
328
+ # `exe_path`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Argument+command_line)
329
+ # and [Process module, Argument
330
+ # `args`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Arguments+args)
368
331
  #
369
- # @return [SpawnOptions]
332
+ # If the first value is a Hash, it is treated as the environment hash. See
333
+ # [Process module, Execution Environment](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Execution+Environment).
370
334
  #
371
- # @raise [ArgumentError] if obj is not a Hash or SpawnOptions
335
+ # @param options_hash [Hash] in addition to the options supported by {run},
336
+ # `merge_output` may be given
372
337
  #
373
- # @api public
338
+ # @option options_hash [Boolean] :merge_output if true, stdout and stderr will be
339
+ # merged into a single capture buffer
374
340
  #
375
- def self.spawn_options(obj)
376
- case obj
377
- when ProcessExecuter::Options::SpawnOptions
378
- obj
379
- when Hash
380
- ProcessExecuter::Options::SpawnOptions.new(**obj)
381
- else
382
- raise ArgumentError, "Expected a Hash or ProcessExecuter::Options::SpawnOptions but got a #{obj.class}"
383
- end
384
- end
385
-
386
- # Convert a hash to a SpawnAndWaitOptions object
341
+ # @option options_hash [Encoding, String] :encoding the encoding to assume for
342
+ # the internal stdout and stderr captures
387
343
  #
388
- # @example
389
- # options_hash = { out: $stdout }
390
- # options = ProcessExecuter.spawn_and_wait_options(options_hash) # =>
391
- # #<ProcessExecuter::Options::SpawnAndWaitOptions:0x00007f8f9b0b3d20 out: $stdout>
392
- # ProcessExecuter.spawn_and_wait_options(options) # =>
393
- # #<ProcessExecuter::Options::SpawnAndWaitOptions:0x00007f8f9b0b3d20 out: $stdout>
344
+ # The default is `Encoding::UTF_8`. This option is overridden by the `stdout_encoding`
345
+ # and `stderr_encoding` options if they are given and not nil.
394
346
  #
395
- # @param obj [Hash, SpawnAndWaitOptions] the object to be converted
347
+ # @option options_hash [Encoding, String, nil] :stdout_encoding the encoding to
348
+ # assume for the internal stdout capture
396
349
  #
397
- # @return [SpawnAndWaitOptions]
350
+ # The default is nil, which means the `encoding` option is used. If this option is
351
+ # is not nil, it is used instead of the `encoding` option.
398
352
  #
399
- # @raise [ArgumentError] if obj is not a Hash or SpawnOptions
353
+ # @option options_hash [Encoding, String, nil] :stderr_encoding the encoding to
354
+ # assume for the internal stderr capture
400
355
  #
401
- # @api public
356
+ # The default is nil, which means the `encoding` option is used. If this option
357
+ # is not nil, it is used instead of the `encoding` option.
402
358
  #
403
- def self.spawn_and_wait_options(obj)
404
- case obj
405
- when ProcessExecuter::Options::SpawnAndWaitOptions
406
- obj
407
- when Hash
408
- ProcessExecuter::Options::SpawnAndWaitOptions.new(**obj)
409
- else
410
- raise ArgumentError, "Expected a Hash or ProcessExecuter::Options::SpawnAndWaitOptions but got a #{obj.class}"
411
- end
412
- end
413
-
414
- # Convert a hash to a RunOptions object
359
+ # @overload run_with_capture(*command, options)
360
+ #
361
+ # @param command [Array<String>] see [Process module, Argument `command_line` or
362
+ # `exe_path`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Argument+command_line)
363
+ # and [Process module, Argument
364
+ # `args`](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Arguments+args)
365
+ #
366
+ # If the first value is a Hash, it is treated as the environment hash. See
367
+ # [Process module, Execution Environment](https://docs.ruby-lang.org/en/3.4/Process.html#module-Process-label-Execution+Environment).
368
+ #
369
+ # @param options [ProcessExecuter::Options::RunWithCaptureOptions]
370
+ #
371
+ # @raise [ProcessExecuter::ArgumentError] If the command or an option is not valid
415
372
  #
416
- # @example
417
- # options_hash = { out: $stdout }
418
- # options = ProcessExecuter.run_options(options_hash) # =>
419
- # #<ProcessExecuter::Options::RunOptions:0x00007f8f9b0b3d20 out: $stdout>
420
- # ProcessExecuter.run_options(options) # =>
421
- # #<ProcessExecuter::Options::RunOptions:0x00007f8f9b0b3d20 out: $stdout>
373
+ # Raised if an invalid option key or value is given, or both an options object
374
+ # and options_hash are given.
422
375
  #
423
- # @param obj [Hash, RunOptions] the object to be converted
376
+ # @raise [ProcessExecuter::SpawnError] `Process.spawn` raised an error before the
377
+ # command was run
424
378
  #
425
- # @return [RunOptions]
379
+ # Raised if the
380
+ # [Process.spawn](https://docs.ruby-lang.org/en/3.4/Process.html#method-c-spawn)
381
+ # method raises an error before the command is run.
426
382
  #
427
- # @raise [ArgumentError] if obj is not a Hash or SpawnOptions
383
+ # @raise [ProcessExecuter::FailedError] If the command ran and failed
384
+ #
385
+ # @raise [ProcessExecuter::SignaledError] If the command ran and terminated due to
386
+ # an unhandled signal
387
+ #
388
+ # @raise [ProcessExecuter::TimeoutError] If the command timed out
389
+ #
390
+ # @raise [ProcessExecuter::ProcessIOError] If there was an exception while
391
+ # collecting subprocess output
392
+ #
393
+ # @return [ProcessExecuter::ResultWithCapture]
394
+ #
395
+ # Where `#stdout` and `#stderr` are strings whose encoding is determined by the
396
+ # `:encoding`, `:stdout_encoding`, or `:stderr_encoding` options.
428
397
  #
429
398
  # @api public
430
399
  #
431
- def self.run_options(obj)
432
- case obj
433
- when ProcessExecuter::Options::RunOptions
434
- obj
435
- when Hash
436
- ProcessExecuter::Options::RunOptions.new(**obj)
400
+ def self.run_with_capture(*command, **options_hash)
401
+ command, options = command_and_options(Options::RunWithCaptureOptions, command, options_hash)
402
+ ProcessExecuter::Commands::RunWithCapture.new(command, options).call
403
+ end
404
+
405
+ # Takes a command and options_hash to determine the options object
406
+ #
407
+ # To support either passing an options object or an options_hash, this method takes
408
+ # a command and an options_hash and returns the command (with the trailing options
409
+ # object removed if one is given) and and options object.
410
+ #
411
+ # @example options hash not empty
412
+ # command, options = ProcessExecuter.command_and_options(
413
+ # ProcessExecuter::Options::RunOptions,
414
+ # ['echo hello'],
415
+ # { out: $stdout }
416
+ # )
417
+ # command #=> ['echo hello']
418
+ # options #=> a new RunOptions instance initialized with the options hash
419
+ #
420
+ # @example options_hash empty, command DOES NOT end with an options object
421
+ # command, options = ProcessExecuter.command_and_options(
422
+ # ProcessExecuter::Options::RunOptions,
423
+ # ['echo hello'],
424
+ # {}
425
+ # )
426
+ # command #=> ['echo hello']
427
+ # options #=> a new RunOptions instance initialized with defaults
428
+ #
429
+ # @example options_hash empty, command ends with an options object
430
+ # command, options = ProcessExecuter.command_and_options(
431
+ # ProcessExecuter::Options::RunOptions,
432
+ # ['echo hello', ProcessExecuter::Options::RunOptions.new(out: $stdout)],
433
+ # {}
434
+ # )
435
+ # command #=> ['echo hello'] # options object is removed
436
+ # options #=> the RunOptions object from command[-1]
437
+ #
438
+ # @param options_class [Class] the class of the options object
439
+ #
440
+ # @param command [Array] the command to be executed (possibly with an instance of
441
+ # options_class at the end)
442
+ #
443
+ # @param options_hash [Hash] the (possibly empty) hash of options
444
+ #
445
+ # @return [Array] An array containing two elements: the command and an options object
446
+ #
447
+ # The command is an array of strings and the options is an instance of the
448
+ # specified options_class.
449
+ #
450
+ # @raise [ProcessExecuter::ArgumentError] If both an options object and an
451
+ # options_hash are given
452
+ #
453
+ # @api private
454
+ #
455
+ private_class_method def self.command_and_options(options_class, command, options_hash)
456
+ if command[-1].is_a?(options_class) && !options_hash.empty?
457
+ raise ProcessExecuter::ArgumentError, 'Provide either an options object or an options hash, not both.'
458
+ end
459
+
460
+ if !options_hash.empty?
461
+ [command, options_class.new(**options_hash)]
462
+ elsif command[-1].is_a?(options_class)
463
+ [command[..-2], command[-1]]
437
464
  else
438
- raise ArgumentError, "Expected a Hash or ProcessExecuter::Options::RunOptions but got a #{obj.class}"
465
+ [command, options_class.new]
439
466
  end
440
467
  end
441
468
  end
469
+
470
+ require 'logger'
471
+ require 'timeout'
472
+
473
+ require 'process_executer/commands'
474
+ require 'process_executer/destinations'
475
+ require 'process_executer/errors'
476
+ require 'process_executer/monitored_pipe'
477
+ require 'process_executer/options'
478
+ require 'process_executer/result'
479
+ require 'process_executer/result_with_capture'