Skip to content

Commit

Permalink
feat: implement ProcessExecuter.run_command
Browse files Browse the repository at this point in the history
Run a command and return the status including stdout and stderr output

See the YARD doc for ProcessExecuter::run_command for full details.
  • Loading branch information
jcouball committed Nov 22, 2024
1 parent 2036645 commit e80b4ba
Show file tree
Hide file tree
Showing 9 changed files with 970 additions and 8 deletions.
162 changes: 160 additions & 2 deletions lib/process_executer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

require 'process_executer/monitored_pipe'
require 'process_executer/options'
require 'process_executer/command'
require 'process_executer/status'

require 'logger'
require 'timeout'

# Execute a command in a subprocess and optionally capture its output
Expand Down Expand Up @@ -55,6 +57,162 @@ def self.spawn(*command, **options_hash)
wait_for_process(pid, options)
end

# Run a command and return the status including stdout and stderr output
#
# @example Run a command given as a single string (uses shell)
# # command must be shell escaped and can have shell expansions and redirections
# command = 'echo "stdout: `pwd`"" && echo "stderr: $HOME" 1>&2'
# result = ProcessExecuter.run_command(command)
# result.success? #=> true
# result.stdout.string #=> "stdout: /Users/james/projects/main-branch/process_executer\n"
# result.stderr.string #=> "stderr: /Users/james\n"
#
# @example Run a command given as an array of strings (does not use shell)
# # command args must be separate strings in the array
# # shell expansions and redirections are not supported
# command = ['git', 'clone', 'https://github.com/main-branch/process_executer']
# result = ProcessExecuter.run_command(*command)
# result.success? #=> true
# result.stdout.string #=> ""
# result.stderr.string #=> "Cloning into 'process_executer'...\n"
#
# @example Run a command with a timeout
# command = ['sleep', '1']
# result = ProcessExecuter.run_command(*command, timeout: 0.01)
# #=> raises ProcessExecuter::Command::TimeoutError which contains the command result
#
# @example Run a command which fails
# command = ['exit 1']
# result = ProcessExecuter.run_command(*command)
# #=> raises ProcessExecuter::Command::FailedError which contains the command result
#
# @example Run a command which exits due to an unhandled signal
# command = ['kill -9 $$']
# result = ProcessExecuter.run_command(*command)
# #=> raises ProcessExecuter::Command::SignaledError which contains the command result
#
# @example Do not raise errors
# command = ['echo "Some error" 1>&2 && exit 1']
# result = ProcessExecuter.run_command(*command, raise_errors: false)
# # An error is not raised
# result.success? #=> false
# result.exitstatus #=> 1
# result.stdout.string #=> ""
# result.stderr.string #=> "Some error\n"
#
# @example Set environment variables
# env = { 'FOO' => 'foo', 'BAR' => 'bar' }
# command = 'echo "$FOO$BAR"'
# result = ProcessExecuter.run_command(env, *command)
# result.stdout.string #=> "foobar\n"
#
# @example Set environment variables when using a command array
# env = { 'GIT_DIR' => '/path/to/.git' }
# command = ['git', 'status']
# result = ProcessExecuter.run_command(env, *command)
# result.stdout.string #=> "On branch main\nYour branch is ..."
#
# @example Unset environment variables
# env = { 'GIT_DIR' => nil } # setting to nil unsets the variable in the environment
# command = ['git', 'status']
# result = ProcessExecuter.run_command(env, *command)
# result.stdout.string #=> "On branch main\nYour branch is ..."
#
# @example Reset existing environment variables will adding new ones
# env = { 'PATH' => '/bin' }
# result = ProcessExecuter.run_command(env, 'echo "Home: $HOME" && echo "Path: $PATH"', unsetenv_others: true)
# result.stdout.string #=> "Home: \n/Path: bin\n"
#
# @example Run command in a different directory
# command = ['pwd']
# result = ProcessExecuter.run_command(*command, chdir: '/tmp')
# result.stdout.string #=> "/tmp\n"
#
# @example Capture stdout and stderr into a single buffer
# command = ['echo "stdout" && echo "stderr" 1>&2']
# result = ProcessExecuter.run_command(*command, merge: true)
# result.stdout.string #=> "stdout\nstderr\n"
# result.stdout.object_id == result.stderr.object_id #=> true
#
# @example Capture to an explicit buffer
# out = StringIO.new
# err = StringIO.new
# command = ['echo "stdout" && echo "stderr" 1>&2']
# result = ProcessExecuter.run_command(*command, out: out, err: err)
# out.string #=> "stdout\n"
# err.string #=> "stderr\n"
# result.stdout.object_id == out.object_id #=> true
# result.stderr.object_id == err.object_id #=> true
#
# @example Capture to a file
# # Same technique can be used for stderr
# out = File.open('stdout.txt', 'w')
# command = ['echo "stdout" && echo "stderr" 1>&2']
# result = ProcessExecuter.run_command(*command, out: out, err: err)
# out.close
# File.read('stdout.txt') #=> "stdout\n"
# # stderr is still captured to a StringIO buffer internally
# result.stderr.string #=> "stderr\n"
#
# @example Capture to multiple writers (e.g. files, buffers, STDOUT, etc.)
# # Same technique can be used for stderr
# out_buffer = StringIO.new
# out_file = File.open('stdout.txt', 'w')
# command = ['echo "stdout" && echo "stderr" 1>&2']
# result = ProcessExecuter.run_command(*command, out: [out_buffer, out_file])
# out_file.close
# out_buffer.string #=> "stdout\n"
# File.read('stdout.txt') #=> "stdout\n"
#
# @param command [Array<String>] The command to run
#
# If the first element of command is a Hash, it is added to the ENV of
# the new process. See [Execution Environment](https://ruby-doc.org/3.3.6/Process.html#module-Process-label-Execution+Environment)
# for more details. The env hash is then removed from the command array.
#
# If the first and only (remaining) command element is a string, it is (possibly) passsed
# to a subshell to run. The command is passed to a subshell run if it begins with a shell
# reserved word or special built-in, or if it contains one or more meta characters.
#
# Care must be taken to properly escape shell metacharacters in the command string.
#
# Otherwise, the command is run bypassing the shell. When bypassing the shell, shell expansions
# and redirections are not supported.
#
# @param logger [Logger] The logger to use
# @param options_hash [Hash] Additional options
# @option options_hash [Numeric] :timeout The maximum seconds to wait for the command to complete
#
# If timeout is zero of nil, the command will not time out. If the command
# times out, it is killed via a SIGKILL signal and {ProcessExecuter::Command::TimeoutError} is raised.
#
# If the command does not respond to SIGKILL, it will hang this method.
#
# @option options_hash [#write] :out (nil) The object to write stdout to
# @option options_hash [#write] :err (nil) The object to write stdout to
# @option options_hash [Boolean] :merge (false) Write both stdout and stderr into the buffer for stdout
# @option options_hash [Boolean] :raise_errors (true) Raise an exception if the command fails
# @option options_hash [Boolean] :unsetenv_others (false) If true, unset all environment variables before
# applying the new ones
# @option options_hash [true, Integer, nil] :pgroup (nil) true or 0: new process group; non-zero: join
# the group, nil: existing group
# @option options_hash [Boolean] :new_pgroup (nil) Create a new process group (Windows only)
# @option options_hash [Integer] :rlimit_resource_name (nil) Set resource limits (see Process.setrlimit)
# @option options_hash [Integer] :umask (nil) Set the umask (see File.umask)
# @option options_hash [Boolean] :close_others (false) If true, close non-standard file descriptors
# @option options_hash [String] :chdir (nil) The directory to run the command in
#
# @raise [ProcessExecuter::Command::FailedError] if the command returned a non-zero exit status
# @raise [ProcessExecuter::Command::SignaledError] if the command exited because of an unhandled signal
# @raise [ProcessExecuter::Command::TimeoutError] if the command timed out
# @raise [ProcessExecuter::Command::ProcessIOError] if an exception was raised while collecting subprocess output
#
# @return [ProcessExecuter::Command::Result] The result of running the command
#
def self.run_command(*command, logger: Logger.new(nil), **options_hash)
ProcessExecuter::Command::Runner.new(logger).call(*command, **options_hash)
end

# Wait for process to terminate
#
# If a timeout is speecified in options, kill the process after options.timeout seconds.
Expand All @@ -68,10 +226,10 @@ def self.spawn(*command, **options_hash)
#
private_class_method def self.wait_for_process(pid, options)
Timeout.timeout(options.timeout) do
ProcessExecuter::Status.new(Process.wait2(pid).last, false)
ProcessExecuter::Status.new(Process.wait2(pid).last, false, options.timeout)
end
rescue Timeout::Error
Process.kill('KILL', pid)
ProcessExecuter::Status.new(Process.wait2(pid).last, true)
ProcessExecuter::Status.new(Process.wait2(pid).last, true, options.timeout)
end
end
12 changes: 12 additions & 0 deletions lib/process_executer/command.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

module ProcessExecuter
# This module contains classes for implementing ProcessExecuter.run_command
module Command; end
end

require_relative 'command/errors'
require_relative 'command/result'
require_relative 'command/runner'

# Runs a command and returns the result
170 changes: 170 additions & 0 deletions lib/process_executer/command/errors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# frozen_string_literal: true

# rubocop:disable Layout/LineLength:

module ProcessExecuter
module Command
# Base class for all ProcessExecuter::Command errors
#
# It is recommended to rescue `ProcessExecuter::Command::Error` to catch any
# runtime error raised by this gem unless you need more specific error handling.
#
# Custom errors are arranged in the following class heirarchy:
#
# ```text
# ::StandardError
# └─> Error
# ├─> CommandError
# │ ├─> FailedError
# │ └─> SignaledError
# │ └─> TimeoutError
# └─> ProcessIOError
# ```
#
# | Error Class | Description |
# | --- | --- |
# | `Error` | This catch-all error serves as the base class for other custom errors. |
# | `CommandError` | A subclass of this error is raised when there is a problem executing a command. |
# | `FailedError` | This error is raised when the command exits with a non-zero status code. |
# | `SignaledError` | This error is 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. |
# | `TimeoutError` | This is a specific type of `SignaledError` that is raised when the command times out and is killed via the SIGKILL signal. This happens if the operation takes longer than the timeout duration (if given). |
# | `ProcessIOError` | An error was encountered reading or writing to the command's subprocess. |
#
# @example Rescuing any error
# begin
# ProcessExecuter.run_command('git', 'status')
# rescue ProcessExecuter::Command::Error => e
# puts "An error occurred: #{e.message}"
# end
#
# @example Rescuing a timeout error
# begin
# timeout_duration = 0.1 # seconds
# ProcessExecuter.run_command('sleep', '1', timeout: timeout_duration)
# rescue ProcessExecuter::TimeoutError => e # Catch the more specific error first!
# puts "Command took too long and timed out: #{e}"
# rescue ProcessExecuter::Error => e
# puts "Some other error occured: #{e}"
# end
#
# @api public
#
class Error < ::StandardError; end

# Raised when a command fails or exits because of an uncaught signal
#
# The command executed, status, stdout, and stderr are available from this
# object.
#
# The Gem will raise a more specific error for each type of failure:
#
# * {FailedError}: when the command exits with a non-zero status
# * {SignaledError}: when the command exits because of an uncaught signal
# * {TimeoutError}: when the command times out
#
# @api public
#
class CommandError < ProcessExecuter::Command::Error
# Create a CommandError object
#
# @example
# `exit 1` # set $? appropriately for this example
# result = ProcessExecuter::Command::Result.new(%w[git status], $?, 'stdout', 'stderr')
# error = ProcessExecuter::Command::CommandError.new(result)
# error.to_s #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"'
#
# @param result [Result] the result of the command including the command,
# status, stdout, and stderr
#
def initialize(result)
@result = result
super(error_message)
end

# The human readable representation of this error
#
# @example
# error.error_message #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"'
#
# @return [String]
#
def error_message
"#{result.command}, status: #{result}, stderr: #{result.stderr_to_s.inspect}"
end

# @attribute [r] result
#
# The result of the command including the command, its status and its output
#
# @example
# error.result #=> #<ProcessExecuter::Command::Result:0x00007f9b1b8b3d20>
#
# @return [Result]
#
attr_reader :result
end

# This error is raised when the command returns a non-zero exitstatus
#
# @api public
#
class FailedError < ProcessExecuter::Command::CommandError; end

# This error is raised when the command exits because of an uncaught signal
#
# @api public
#
class SignaledError < ProcessExecuter::Command::CommandError; end

# This error is raised when the command takes longer than the configured timeout
#
# @example
# result.status.timeout? #=> true
#
# @api public
#
class TimeoutError < ProcessExecuter::Command::SignaledError
# Create a TimeoutError object
#
# @example
# command = %w[sleep 10]
# timeout_duration = 1
# status = ProcessExecuter.spawn(*command, timeout: timeout_duration)
# result = Result.new(command, status, 'stdout', 'err output')
# error = TimeoutError.new(result, timeout_duration)
# error.error_message
# #=> '["sleep", "10"], status: pid 70144 SIGKILL (signal 9), stderr: "err output", timed out after 1s'
#
# @param result [Result] the result of the command including the git command,
# status, stdout, and stderr
#
# @param timeout_duration [Numeric] the amount of time the subprocess was allowed
# to run before being killed
#
def initialize(result, timeout_duration)
@timeout_duration = timeout_duration
super(result)
end

# The amount of time the subprocess was allowed to run before being killed
#
# @example
# `kill -9 $$` # set $? appropriately for this example
# result = Result.new(%w[git status], $?, '', "killed")
# error = TimeoutError.new(result, 10)
# error.timeout_duration #=> 10
#
# @return [Numeric]
#
attr_reader :timeout_duration
end

# Raised when the output of a git command can not be read
#
# @api public
#
class ProcessIOError < ProcessExecuter::Command::Error; end
end
end

# rubocop:enable Layout/LineLength:
Loading

0 comments on commit e80b4ba

Please sign in to comment.