process_executer 1.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c875872382e447e7af6dfec78af45242baa13c7b832d5c25f3e146bbc2b9f1d
4
- data.tar.gz: a68da52f9b1dfae542386a0d61abc85cdc0d86ee0471c4893692061db661f4e5
3
+ metadata.gz: d2bd50be1f3683bc2c8210451c45e270e7072992ec48897a8bbb16cf10790c8b
4
+ data.tar.gz: e0d1ba5a7c5571b0059db55537726b68baf6017a66941883953ece63f5a58b53
5
5
  SHA512:
6
- metadata.gz: a0f6b19a6570dab6843b0d76af1839496ae7ff0651a3f485d6465414b3eb6f0d4a78236bc9ae80ad1b4d7e2e43d8c093ef2733e047eaadb27f3c2e7f174e5f82
7
- data.tar.gz: 65da6657d7b821f2b0641bcad6278ded70fd78c06497dfa93fa67ab52c6bb0b57443fa6aa92b5cc160b68ff3b4b68165208b2ff77eea0f4b50542e1243a62c8e
6
+ metadata.gz: bdaab34abbd99de650933f367eedcb40e7a2538d1f48ab8eda6f5df5da3d5f1748543eb28f54f21cc3557c64d34575001da2158caf2f30cc768bec88f657e16c
7
+ data.tar.gz: 920227450b1da0c56aa3987a2ff1bbeb2c73cd093d04c1318ebb39d02302a408682d0d40c1668eb1f9b37d71f58fe47a6c4b101b75e701fdbaf3db2cdb845b26
data/.yardopts CHANGED
@@ -3,6 +3,4 @@
3
3
  --markup-provider=redcarpet
4
4
  --markup markdown
5
5
  - CHANGELOG.md
6
- - CONTRIBUTING.md
7
- - RELEASING.md
8
6
  - LICENSE.txt
data/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to the process_executer gem will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## v1.3.0 (2025-02-26)
9
+
10
+ [Full Changelog](https://github.com/main-branch/process_executer/compare/v1.2.0..v1.3.0)
11
+
12
+ Changes since v1.2.0:
13
+
14
+ * d1e189b build: add Ruby 3.4 to the CI workflow
15
+ * e805dfc feat: implement ProcessExecuter.run_command
16
+ * bad822f fix: update the yard build in the rake file and update included files
17
+ * 6fbdc5e feat: allow #spawn to accept file descriptors for redirection destination
18
+ * d745685 test: make it so that tests do not give unnecessary output
19
+
8
20
  ## v1.2.0 (2024-10-10)
9
21
 
10
22
  [Full Changelog](https://github.com/main-branch/process_executer/compare/v1.1.2..v1.2.0)
data/README.md CHANGED
@@ -11,6 +11,7 @@ Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?log
11
11
  [![Slack](https://img.shields.io/badge/slack-main--branch/process__executer-yellow.svg?logo=slack)](https://main-branch.slack.com/archives/C07NG2BPG8Y)
12
12
 
13
13
  * [Usage](#usage)
14
+ * [ProcessExecuter.run](#processexecuterrun)
14
15
  * [ProcessExecuter::MonitoredPipe](#processexecutermonitoredpipe)
15
16
  * [ProcessExecuter.spawn](#processexecuterspawn)
16
17
  * [Installation](#installation)
@@ -29,6 +30,36 @@ gem is hosted on RubyGems.org. Read below of an overview and several examples.
29
30
 
30
31
  This gem contains the following important classes:
31
32
 
33
+ ### ProcessExecuter.run
34
+
35
+ `ProcessExecuter.run` execute the given command as a subprocess blocking until it is finished.
36
+
37
+ A Result object is returned which includes the process's status and output.
38
+
39
+ Supports the same features as
40
+ [Process.spawn](https://docs.ruby-lang.org/en/3.3/Process.html#method-c-spawn).
41
+ In addition, it (1) blocks until the command has exited, (2) captures stdout and
42
+ stderr to a buffer or file, and (3) can optionally kill the command if it exceeds
43
+ an timeout.
44
+
45
+ This command takes two forms:
46
+
47
+ 1. When passing a single string the command is passed to a shell:
48
+
49
+ `ProcessExecuter.run([env, ] command_line, options = {}) ->` {ProcessExecuter::Command::Result}
50
+
51
+ 2. When passing an array of strings the command is run directly (bypassing the shell):
52
+
53
+ `ProcessExecuter.run([env, ] exe_path, *args, options = {}) ->` {ProcessExecuter::Command::Result}
54
+
55
+ Argument env, if given, is a hash that affects ENV for the new process; see
56
+ [Execution
57
+ Environment](https://docs.ruby-lang.org/en/3.3/Process.html#module-Process-label-Execution+Environment).
58
+
59
+ Argument options is a hash of options for the new process; see the options listed below.
60
+
61
+ See comprehensive examples in the YARD documentation for this method.
62
+
32
63
  ### ProcessExecuter::MonitoredPipe
33
64
 
34
65
  `ProcessExecuter::MonitoredPipe` streams data sent through a pipe to one or more writers.
data/Rakefile CHANGED
@@ -7,7 +7,7 @@ desc 'Run the same tasks that the CI build will run'
7
7
  if RUBY_PLATFORM == 'java'
8
8
  task default: %w[spec rubocop bundle:audit build]
9
9
  else
10
- task default: %w[spec rubocop yard yard:audit yard:coverage bundle:audit build]
10
+ task default: %w[spec rubocop yard bundle:audit build]
11
11
  end
12
12
 
13
13
  # Bundler Audit
@@ -50,28 +50,33 @@ RuboCop::RakeTask.new
50
50
  # YARD
51
51
 
52
52
  unless RUBY_PLATFORM == 'java'
53
- # YARD
53
+ # yard:build
54
54
 
55
55
  require 'yard'
56
- YARD::Rake::YardocTask.new do |t|
57
- t.files = %w[lib/**/*.rb examples/**/*]
56
+
57
+ YARD::Rake::YardocTask.new('yard:build') do |t|
58
+ t.files = %w[lib/**/*.rb]
59
+ t.stats_options = ['--list-undoc']
58
60
  end
59
61
 
60
62
  CLEAN << '.yardoc'
61
63
  CLEAN << 'doc'
62
64
 
63
- # Yardstick
65
+ # yard:audit
64
66
 
65
67
  desc 'Run yardstick to show missing YARD doc elements'
66
68
  task :'yard:audit' do
67
69
  sh "yardstick 'lib/**/*.rb'"
68
70
  end
69
71
 
70
- # Yardstick coverage
72
+ # yard:coverage
71
73
 
72
74
  require 'yardstick/rake/verify'
73
75
 
74
76
  Yardstick::Rake::Verify.new(:'yard:coverage') do |verify|
75
77
  verify.threshold = 100
78
+ verify.require_exact_threshold = false
76
79
  end
80
+
81
+ task yard: %i[yard:build yard:audit yard:coverage]
77
82
  end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Layout/LineLength
4
+
5
+ module ProcessExecuter
6
+ module Command
7
+ # Base class for all ProcessExecuter::Command errors
8
+ #
9
+ # It is recommended to rescue `ProcessExecuter::Command::Error` to catch any
10
+ # runtime error raised by this gem unless you need more specific error handling.
11
+ #
12
+ # Custom errors are arranged in the following class hierarchy:
13
+ #
14
+ # ```text
15
+ # ::StandardError
16
+ # └─> Error
17
+ # ├─> CommandError
18
+ # │ ├─> FailedError
19
+ # │ └─> SignaledError
20
+ # │ └─> TimeoutError
21
+ # └─> ProcessIOError
22
+ # ```
23
+ #
24
+ # | Error Class | Description |
25
+ # | --- | --- |
26
+ # | `Error` | This catch-all error serves as the base class for other custom errors. |
27
+ # | `CommandError` | A subclass of this error is raised when there is a problem executing a command. |
28
+ # | `FailedError` | Raised when the command exits with a non-zero status code. |
29
+ # | `SignaledError` | Raised when the command is terminated as a result of receiving a signal. This could happen if the process is forcibly terminated or if there is a serious system error. |
30
+ # | `TimeoutError` | This is a specific type of `SignaledError` that is raised when the command times out and is killed via the SIGKILL signal. Raised when the operation takes longer than the specified timeout duration (if provided). |
31
+ # | `ProcessIOError` | Raised when an error was encountered reading or writing to the command's subprocess. |
32
+ #
33
+ # @example Rescuing any error
34
+ # begin
35
+ # ProcessExecuter.run_command('git', 'status')
36
+ # rescue ProcessExecuter::Command::Error => e
37
+ # puts "An error occurred: #{e.message}"
38
+ # end
39
+ #
40
+ # @example Rescuing a timeout error
41
+ # begin
42
+ # timeout_duration = 0.1 # seconds
43
+ # ProcessExecuter.run_command('sleep', '1', timeout: timeout_duration)
44
+ # rescue ProcessExecuter::TimeoutError => e # Catch the more specific error first!
45
+ # puts "Command took too long and timed out: #{e}"
46
+ # rescue ProcessExecuter::Error => e
47
+ # puts "Some other error occured: #{e}"
48
+ # end
49
+ #
50
+ # @api public
51
+ #
52
+ class Error < ::StandardError; end
53
+
54
+ # Raised when a command fails or exits because of an uncaught signal
55
+ #
56
+ # The command executed, status, stdout, and stderr are available from this
57
+ # object.
58
+ #
59
+ # The Gem will raise a more specific error for each type of failure:
60
+ #
61
+ # * {FailedError}: when the command exits with a non-zero status
62
+ # * {SignaledError}: when the command exits because of an uncaught signal
63
+ # * {TimeoutError}: when the command times out
64
+ #
65
+ # @api public
66
+ #
67
+ class CommandError < ProcessExecuter::Command::Error
68
+ # Create a CommandError object
69
+ #
70
+ # @example
71
+ # `exit 1` # set $? appropriately for this example
72
+ # result = ProcessExecuter::Command::Result.new(%w[git status], $?, 'stdout', 'stderr')
73
+ # error = ProcessExecuter::Command::CommandError.new(result)
74
+ # error.to_s #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"'
75
+ #
76
+ # @param result [Result] The result of the command including the command,
77
+ # status, stdout, and stderr
78
+ #
79
+ def initialize(result)
80
+ @result = result
81
+ super(error_message)
82
+ end
83
+
84
+ # The human readable representation of this error
85
+ #
86
+ # @example
87
+ # error.error_message #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"'
88
+ #
89
+ # @return [String]
90
+ #
91
+ def error_message
92
+ "#{result.command}, status: #{result}, stderr: #{result.stderr_to_s.inspect}"
93
+ end
94
+
95
+ # @attribute [r] result
96
+ #
97
+ # The result of the command including the command, its status and its output
98
+ #
99
+ # @example
100
+ # error.result #=> #<ProcessExecuter::Command::Result:0x00007f9b1b8b3d20>
101
+ #
102
+ # @return [Result]
103
+ #
104
+ attr_reader :result
105
+ end
106
+
107
+ # Raised when the command returns a non-zero exitstatus
108
+ #
109
+ # @api public
110
+ #
111
+ class FailedError < ProcessExecuter::Command::CommandError; end
112
+
113
+ # Raised when the command exits because of an uncaught signal
114
+ #
115
+ # @api public
116
+ #
117
+ class SignaledError < ProcessExecuter::Command::CommandError; end
118
+
119
+ # Raised when the command takes longer than the configured timeout
120
+ #
121
+ # @example
122
+ # result.status.timeout? #=> true
123
+ #
124
+ # @api public
125
+ #
126
+ class TimeoutError < ProcessExecuter::Command::SignaledError
127
+ # Create a TimeoutError object
128
+ #
129
+ # @example
130
+ # command = %w[sleep 10]
131
+ # timeout_duration = 1
132
+ # status = ProcessExecuter.spawn(*command, timeout: timeout_duration)
133
+ # result = Result.new(command, status, 'stdout', 'err output')
134
+ # error = TimeoutError.new(result, timeout_duration)
135
+ # error.error_message
136
+ # #=> '["sleep", "10"], status: pid 70144 SIGKILL (signal 9), stderr: "err output", timed out after 1s'
137
+ #
138
+ # @param result [Result] The result of the command including the git command,
139
+ # status, stdout, and stderr
140
+ #
141
+ # @param timeout_duration [Numeric] The duration the subprocess was allowed
142
+ # to run before being terminated
143
+ #
144
+ def initialize(result, timeout_duration)
145
+ @timeout_duration = timeout_duration
146
+ super(result)
147
+ end
148
+
149
+ # The amount of time the subprocess was allowed to run before being killed
150
+ #
151
+ # @example
152
+ # `kill -9 $$` # set $? appropriately for this example
153
+ # result = Result.new(%w[git status], $?, '', "killed")
154
+ # error = TimeoutError.new(result, 10)
155
+ # error.timeout_duration #=> 10
156
+ #
157
+ # @return [Numeric]
158
+ #
159
+ attr_reader :timeout_duration
160
+ end
161
+
162
+ # Raised when the output of a command can not be read
163
+ #
164
+ # @api public
165
+ #
166
+ class ProcessIOError < ProcessExecuter::Command::Error; end
167
+ end
168
+ end
169
+
170
+ # rubocop:enable Layout/LineLength
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ module ProcessExecuter
6
+ module Command
7
+ # A wrapper around {ProcessExecuter::Status} which adds captured command output
8
+ #
9
+ # This class is used to represent the result of a subprocess execution, combining
10
+ # the process status with the captured output for easier access and manipulation.
11
+ #
12
+ # Features:
13
+ # * Provides access to the process's status, stdout, and stderr.
14
+ # * Allows conversion of stdout and stderr buffers to strings.
15
+ #
16
+ # @example Create a Result object
17
+ # status = ProcessExecuter.spawn(*command, timeout:, out:, err:)
18
+ # result = ProcessExecuter::Command::Result.new(command, status, out_buffer.string, err_buffer.string)
19
+ #
20
+ # @api public
21
+ #
22
+ class Result < SimpleDelegator
23
+ # Create a new Result object
24
+ # @example
25
+ # status = ProcessExecuter.spawn(*command, timeout:, out:, err:)
26
+ # Result.new(command, status, out_buffer.string, err_buffer.string)
27
+ # @param command [Array<String>] The command that was executed
28
+ # @param status [ProcessExecuter::Status] The status of the process
29
+ # @param stdout [String] The stdout output from the process
30
+ # @param stderr [String] The stderr output from the process
31
+ def initialize(command, status, stdout, stderr)
32
+ super(status)
33
+ @command = command
34
+ @stdout = stdout
35
+ @stderr = stderr
36
+ end
37
+
38
+ # The command that was run
39
+ # @example
40
+ # result.command #=> %w[git status]
41
+ # @return [Array<String>]
42
+ attr_reader :command
43
+
44
+ # The captured stdout output from the process
45
+ # @example
46
+ # result.stdout #=> "On branch master\nnothing to commit, working tree clean\n"
47
+ # @return [String]
48
+ attr_reader :stdout
49
+
50
+ # The captured stderr output from the process
51
+ # @example
52
+ # result.stderr #=> "ERROR: file not found"
53
+ # @return [String]
54
+ attr_reader :stderr
55
+
56
+ # Return the stdout output as a string
57
+ # @example When stdout is a StringIO containing "Hello World"
58
+ # result.stdout_to_s #=> "Hello World"
59
+ # @example When stdout is a File object
60
+ # result.stdout_to_s #=> #<File:/tmp/output.txt>
61
+ # @return [String, Object] Returns a String if stdout is a StringIO; otherwise, returns the stdout object
62
+ def stdout_to_s
63
+ stdout.respond_to?(:string) ? stdout.string : stdout
64
+ end
65
+
66
+ # Return the stderr output as a string
67
+ # @example When stderr is a StringIO containing "Hello World"
68
+ # result.stderr_to_s #=> "Hello World"
69
+ # @example When stderr is a File object
70
+ # result.stderr_to_s #=> #<File:/tmp/output.txt>
71
+ # @return [String, Object] Returns a String if stderr is a StringIO; otherwise, returns the stderr object
72
+ def stderr_to_s
73
+ stderr.respond_to?(:string) ? stderr.string : stderr
74
+ end
75
+ end
76
+ end
77
+ end
@@ -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
- # SimpleCov under JRuby reports the begin statement as not covered, but it is
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ProcessExecuter
4
4
  # The current Gem version
5
- VERSION = '1.2.0'
5
+ VERSION = '1.3.0'
6
6
  end
@@ -2,21 +2,36 @@
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
- # Execute a command in a subprocess and optionally capture its output
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 specified command as a subprocess and return the exit status
28
+ # Execute the given command as a subprocess and return the exit status
15
29
  #
16
- # This is a convenience method that calls Process.spawn and blocks until the
17
- # command has terminated.
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 send the SIGKILL signal if it does not terminate within
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
@@ -44,10 +59,10 @@ module ProcessExecuter
44
59
  # @see ProcessExecuter::Options#initialize See ProcessExecuter::Options#initialize
45
60
  # for options that may be specified
46
61
  #
47
- # @param command [Array<String>] the command to execute
48
- # @param options_hash [Hash] the options to use when exectuting the command
62
+ # @param command [Array<String>] The command to execute
63
+ # @param options_hash [Hash] The options to use when executing the command
49
64
  #
50
- # @return [Process::Status] the exit status of the proceess
65
+ # @return [Process::Status] the exit status of the process
51
66
  #
52
67
  def self.spawn(*command, **options_hash)
53
68
  options = ProcessExecuter::Options.new(**options_hash)
@@ -55,23 +70,210 @@ module ProcessExecuter
55
70
  wait_for_process(pid, options)
56
71
  end
57
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
+
58
260
  # Wait for process to terminate
59
261
  #
60
- # If a timeout is speecified in options, kill the process after options.timeout seconds.
262
+ # If a timeout is specified in options, terminate the process after options.timeout seconds.
61
263
  #
62
- # @param pid [Integer] the process id
264
+ # @param pid [Integer] the process ID
63
265
  # @param options [ProcessExecuter::Options] the options used
64
266
  #
65
- # @return [ProcessExecuter::Status] the status of the process
267
+ # @return [ProcessExecuter::Status] the process status including Process::Status attributes and a timeout flag
66
268
  #
67
269
  # @api private
68
270
  #
69
271
  private_class_method def self.wait_for_process(pid, options)
70
272
  Timeout.timeout(options.timeout) do
71
- ProcessExecuter::Status.new(Process.wait2(pid).last, false)
273
+ ProcessExecuter::Status.new(Process.wait2(pid).last, false, options.timeout)
72
274
  end
73
275
  rescue Timeout::Error
74
276
  Process.kill('KILL', pid)
75
- ProcessExecuter::Status.new(Process.wait2(pid).last, true)
277
+ ProcessExecuter::Status.new(Process.wait2(pid).last, true, options.timeout)
76
278
  end
77
279
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: process_executer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Couball
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-10-11 00:00:00.000000000 Z
11
+ date: 2025-02-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler-audit
@@ -218,6 +218,10 @@ files:
218
218
  - README.md
219
219
  - Rakefile
220
220
  - lib/process_executer.rb
221
+ - lib/process_executer/command.rb
222
+ - lib/process_executer/command/errors.rb
223
+ - lib/process_executer/command/result.rb
224
+ - lib/process_executer/command/runner.rb
221
225
  - lib/process_executer/monitored_pipe.rb
222
226
  - lib/process_executer/options.rb
223
227
  - lib/process_executer/status.rb
@@ -231,8 +235,8 @@ metadata:
231
235
  allowed_push_host: https://rubygems.org
232
236
  homepage_uri: https://github.com/main-branch/process_executer
233
237
  source_code_uri: https://github.com/main-branch/process_executer
234
- documentation_uri: https://rubydoc.info/gems/process_executer/1.2.0
235
- changelog_uri: https://rubydoc.info/gems/process_executer/1.2.0/file/CHANGELOG.md
238
+ documentation_uri: https://rubydoc.info/gems/process_executer/1.3.0
239
+ changelog_uri: https://rubydoc.info/gems/process_executer/1.3.0/file/CHANGELOG.md
236
240
  rubygems_mfa_required: 'true'
237
241
  post_install_message:
238
242
  rdoc_options: []