lib/gdk/shellout.rb (136 lines of code) (raw):
# frozen_string_literal: true
require 'open3'
require 'io/wait'
require 'benchmark'
module GDK
# Controls execution of commands delegated to the running shell
class Shellout
attr_reader :args, :env, :opts, :stderr_str
DEFAULT_EXECUTE_DISPLAY_OUTPUT = true
DEFAULT_EXECUTE_RETRY_ATTEMPTS = 0
DEFAULT_EXECUTE_RETRY_DELAY_SECS = 2
BLOCK_SIZE = 1024
ShelloutBaseError = Class.new(StandardError)
ExecuteCommandFailedError = Class.new(ShelloutBaseError)
StreamCommandFailedError = Class.new(ShelloutBaseError)
def initialize(*args, **opts)
@args = args.flatten
@env = opts.delete(:env) || {}
@opts = opts
end
def command
@command ||= args.join(' ')
end
def execute(display_output: true, display_error: true, retry_attempts: DEFAULT_EXECUTE_RETRY_ATTEMPTS, retry_delay_secs: DEFAULT_EXECUTE_RETRY_DELAY_SECS)
retried ||= false
GDK::Output.debug("command=[#{command}], opts=[#{opts}], display_output=[#{display_output}], retry_attempts=[#{retry_attempts}]")
duration = Benchmark.realtime do
display_output ? stream : try_run
end
GDK::Output.debug("result: success?=[#{success?}], stdout=[#{read_stdout}], stderr=[#{read_stderr}], duration=[#{duration.round(2)} seconds]")
raise ExecuteCommandFailedError, command unless success?
if retried
retry_success_message = "'#{command}' succeeded after retry."
GDK::Output.success(retry_success_message)
end
self
rescue StreamCommandFailedError, ExecuteCommandFailedError => e
error_message = "'#{command}' failed."
if (retry_attempts -= 1).negative?
GDK::Output.error(error_message, e) if display_error
self
else
retried = true
error_message += " Retrying in #{retry_delay_secs} secs.."
GDK::Output.error(error_message, e) if display_error
sleep(retry_delay_secs)
retry
end
end
# Executes the command while printing the output from both stdout and stderr
#
# This command will stream each individual character from a separate thread
# making it possible to visualize interactive progress bar.
def stream(extra_options = {})
@stdout_str = ''
@stderr_str = ''
# Inspiration: https://nickcharlton.net/posts/ruby-subprocesses-with-stdout-stderr-streams.html
Open3.popen3(env, *args, opts.merge(extra_options)) do |_stdin, stdout, stderr, thread|
@status = print_output_from_thread(thread, stdout, stderr)
end
read_stdout
rescue Errno::ENOENT => e
print_err(e.message)
raise StreamCommandFailedError, e
end
def readlines(limit = -1, &block)
@stdout_str = ''
@stderr_str = ''
lines = []
Open3.popen2(env, *args, opts) do |_stdin, stdout, thread|
stdout.each_line do |line|
if limit == -1 || lines.count < limit
lines << line.chomp
yield line if block
end
end
thread.join
@status = thread.value
end
@stdout_str = lines.join("\n")
lines
end
def run
capture
read_stdout
end
def try_run
capture(err: '/dev/null')
read_stdout
rescue Errno::ENOENT
''
end
def read_stdout
clean_string(@stdout_str.to_s.chomp)
end
def read_stderr
clean_string(@stderr_str.to_s.chomp)
end
# Return whether last run command was successful (exit 0)
#
# @return [Boolean] whether last run command was successful
def success?
return false unless @status
@status.success?
end
# Exit code from last run command
#
# @return [Integer] exit code
def exit_code
return nil unless @status
@status.exitstatus
end
private
def print_output_from_thread(thread, stdout, stderr)
threads = Array(thread)
threads << thread_read(stdout, method(:print_out))
threads << thread_read(stderr, method(:print_err))
threads.each(&:join)
thread.value
end
def clean_string(str)
str.sub("\r\e", '').chomp
end
def capture(extra_options = {})
@stdout_str, @stderr_str, @status = Open3.capture3(env, *args, opts.merge(extra_options))
end
def thread_read(io, meth)
logger = Support::Rake::TaskLogger.current
Thread.new do
Support::Rake::TaskLogger.set_current!(logger)
until io.eof?
ready = io.wait_readable
next unless ready
input = GDK::Output.ensure_utf8(io.read_nonblock(BLOCK_SIZE))
meth.call(input)
logger&.record_input(input) if input
end
end
end
def print_out(msg)
@stdout_str += msg
GDK::Output.print(msg)
end
def print_err(msg)
@stderr_str += msg
GDK::Output.print(msg, stderr: true)
end
end
end