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