module ProcessHelper
Makes it easy to spawn Ruby sub-processes with guaranteed exit status handling, capturing and/or suppressing combined STDOUT and STDERR streams, providing STDIN input, timeouts, and running via a pseudo terminal.
Full documentation at github.com/thewoolleyman/process_helper
Constants
- VERSION
Don't forget to keep version in sync with gemspec
Public Instance Methods
process(cmd, options = {})
click to toggle source
# File lib/process_helper.rb, line 18 def process(cmd, options = {}) cmd = cmd.to_s fail ProcessHelper::EmptyCommandError, 'command must not be empty' if cmd.empty? options = options.dup options_processing(options) puts cmd if options[:log_cmd] output, process_status = if options[:pseudo_terminal] process_with_pseudo_terminal(cmd, options) else process_with_popen(cmd, options) end handle_exit_status(cmd, options, output, process_status) output end
Private Instance Methods
convert_scalar_expected_exit_status_to_array(options)
click to toggle source
# File lib/process_helper.rb, line 300 def convert_scalar_expected_exit_status_to_array(options) return if options[:expected_exit_status].is_a?(Array) options[:expected_exit_status] = [options[:expected_exit_status]] end
convert_short_options(options)
click to toggle source
# File lib/process_helper.rb, line 242 def convert_short_options(options) valid_option_pairs.each do |pair| long, short = pair options[long] = options.delete(short) unless options[short].nil? end end
create_exception_message(cmd, exit_status, expected_exit_status)
click to toggle source
# File lib/process_helper.rb, line 156 def create_exception_message(cmd, exit_status, expected_exit_status) if expected_exit_status == [0] result_msg = 'failed' exit_status_msg = '' elsif !expected_exit_status.include?(0) result_msg = 'succeeded but was expected to fail' exit_status_msg = " (expected #{expected_exit_status})" else result_msg = 'did not exit with one of the expected exit statuses' exit_status_msg = " (expected #{expected_exit_status})" end "Command #{result_msg}, #{exit_status}#{exit_status_msg}. " \ "Command: `#{cmd}`." end
format_char(ch)
click to toggle source
# File lib/process_helper.rb, line 131 def format_char(ch) ch == '%' ? '%%' : ch end
get_output(stdin, stdout_and_stderr, options)
click to toggle source
rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
# File lib/process_helper.rb, line 81 def get_output(stdin, stdout_and_stderr, options) input = options[:input] always_puts_output = (options[:puts_output] == :always) timeout = options[:timeout] output = '' begin begin until input.eof? Timeout.timeout(timeout) do in_ch = input.read_nonblock(1) stdin.write_nonblock(in_ch) end stdin.flush end ch = nil loop do Timeout.timeout(timeout) do ch = stdout_and_stderr.read_nonblock(1) end break unless ch printf format_char(ch) if always_puts_output output += ch stdout_and_stderr.flush end rescue EOFError return output rescue IO::WaitReadable result = IO.select([stdout_and_stderr], nil, nil, timeout) raise Timeout::Error if result.nil? retry rescue IO::WaitWritable result = IO.select(nil, [stdin], nil, timeout) raise Timeout::Error if result.nil? retry rescue Errno::EIO # GNU/Linux raises EIO on read operation when pty slave is closed - see pty.rb docs return output if options[:pseudo_terminal] raise end rescue Timeout::Error handle_timeout_error(output, options) ensure stdout_and_stderr.close stdin.close end # TODO: Why do we sometimes get here with no EOFError occurring, but instead # via IO::WaitReadable with a nil select result? (via popen, not sure if via tty) output end
handle_exit_status(cmd, options, output, process_status)
click to toggle source
# File lib/process_helper.rb, line 144 def handle_exit_status(cmd, options, output, process_status) expected_exit_status = options[:expected_exit_status] return if expected_exit_status.include?(process_status.exitstatus) exception_message = create_exception_message(cmd, process_status, expected_exit_status) if options[:include_output_in_exception] exception_message += " Command output: \"#{output}\"" end puts_output_only_on_exception(options, output) fail ProcessHelper::UnexpectedExitStatusError, exception_message end
handle_timeout_error(output, options, seconds = nil, additional_msg = nil)
click to toggle source
# File lib/process_helper.rb, line 135 def handle_timeout_error(output, options, seconds = nil, additional_msg = nil) msg = "Timed out after #{options.fetch(:timeout, seconds)} seconds." msg += " #{additional_msg}" if additional_msg if options[:include_output_in_exception] msg += " Command output prior to timeout: \"#{output}\"" end fail(TimeoutError, msg) end
options_processing(options)
click to toggle source
# File lib/process_helper.rb, line 177 def options_processing(options) validate_long_vs_short_option_uniqueness(options) convert_short_options(options) validate_input_option(options[:input]) if options[:input] set_option_defaults(options) validate_option_values(options) convert_scalar_expected_exit_status_to_array(options) warn_if_output_may_be_suppressed_on_error(options) end
process_with_popen(cmd, options)
click to toggle source
# File lib/process_helper.rb, line 36 def process_with_popen(cmd, options) Open3.popen2e(cmd) do |stdin, stdout_and_stderr, wait_thr| begin output = get_output(stdin, stdout_and_stderr, options) rescue TimeoutError # ensure the thread is killed wait_thr.kill raise end process_status = wait_thr.value return [output, process_status] end end
process_with_pseudo_terminal(cmd, options)
click to toggle source
# File lib/process_helper.rb, line 50 def process_with_pseudo_terminal(cmd, options) max_seconds_to_wait_for_pid_to_exit = options[:timeout] || 60 PTY.spawn(cmd) do |stdout_and_stderr, stdin, pid| output = get_output(stdin, stdout_and_stderr, options) # TODO: come up with a test that illustrates pid not exiting by the time PTY exits process_status = nil begin Timeout.timeout(max_seconds_to_wait_for_pid_to_exit) do process_status = PTY.check(pid) until process_status sleep 0.1 unless process_status end rescue Timeout::Error additional_msg = "Pid #{pid} did not exit after its pseudo-terminal (PTY) returned." handle_timeout_error(output, options, max_seconds_to_wait_for_pid_to_exit, additional_msg) end return [output, process_status] end end
puts_output_only_on_exception(options, output)
click to toggle source
# File lib/process_helper.rb, line 172 def puts_output_only_on_exception(options, output) return if options[:puts_output] == :always puts output if options[:puts_output] == :error end
quote_and_join_pair(pair)
click to toggle source
# File lib/process_helper.rb, line 296 def quote_and_join_pair(pair) pair.map { |o| "'#{o}'" }.join(',') end
set_option_defaults(options)
click to toggle source
rubocop:disable Style/AccessorMethodName
# File lib/process_helper.rb, line 195 def set_option_defaults(options) options[:puts_output] = :always if options[:puts_output].nil? options[:include_output_in_exception] = true if options[:include_output_in_exception].nil? options[:pseudo_terminal] = false if options[:pseudo_terminal].nil? options[:expected_exit_status] = [0] if options[:expected_exit_status].nil? options[:input] = StringIO.new(options[:input].to_s) unless options[:input].is_a?(StringIO) options[:log_cmd] = false if options[:log_cmd].nil? end
valid_option_pairs()
click to toggle source
# File lib/process_helper.rb, line 204 def valid_option_pairs pairs = [ %w(expected_exit_status exp_st), %w(include_output_in_exception out_ex), %w(input in), %w(pseudo_terminal pty), %w(puts_output out), %w(timeout kill), %w(log_cmd log), ] pairs.each do |pair| pair.each_with_index do |opt, index| pair[index] = opt.to_sym end end end
valid_options()
click to toggle source
# File lib/process_helper.rb, line 221 def valid_options valid_option_pairs.flatten end
validate_boolean(pair, value)
click to toggle source
# File lib/process_helper.rb, line 280 def validate_boolean(pair, value) fail( ProcessHelper::InvalidOptionsError, "#{quote_and_join_pair(pair)} options must be a boolean" ) unless value == true || value == false end
validate_input_option(input_option)
click to toggle source
# File lib/process_helper.rb, line 187 def validate_input_option(input_option) fail( ProcessHelper::InvalidOptionsError, "#{quote_and_join_pair(%w(input in))} options must be a String or a StringIO" ) unless input_option.is_a?(String) || input_option.is_a?(StringIO) end
validate_integer(pair, value)
click to toggle source
# File lib/process_helper.rb, line 263 def validate_integer(pair, value) valid = case when value.is_a?(Integer) true when value.is_a?(Array) && value.all? { |v| v.is_a?(Integer) } true else false end fail( ProcessHelper::InvalidOptionsError, "#{quote_and_join_pair(pair)} options must be an Integer or an array of Integers" ) unless valid end
validate_long_vs_short_option_uniqueness(options)
click to toggle source
# File lib/process_helper.rb, line 225 def validate_long_vs_short_option_uniqueness(options) invalid_options = (options.keys - valid_options) fail( ProcessHelper::InvalidOptionsError, "Invalid option(s) '#{invalid_options.join(', ')}' given. " \ "Valid options are: #{valid_options.join(', ')}") unless invalid_options.empty? valid_option_pairs.each do |pair| long_option_name, short_option_name = pair both_long_and_short_option_specified = options[long_option_name] && options[short_option_name] next unless both_long_and_short_option_specified fail( ProcessHelper::InvalidOptionsError, "Cannot specify both '#{long_option_name}' and '#{short_option_name}'") end end
validate_option_values(options)
click to toggle source
# File lib/process_helper.rb, line 249 def validate_option_values(options) options.each do |option, value| valid_option_pairs.each do |pair| long_option_name, _ = pair next unless option == long_option_name validate_integer(pair, value) if option.to_s == 'expected_exit_status' validate_boolean(pair, value) if option.to_s == 'include_output_in_exception' validate_boolean(pair, value) if option.to_s == 'pseudo_terminal' validate_puts_output(pair, value) if option.to_s == 'puts_output' validate_boolean(pair, value) if option.to_s == 'log_cmd' end end end
validate_puts_output(pair, value)
click to toggle source
# File lib/process_helper.rb, line 287 def validate_puts_output(pair, value) valid_values = [:always, :error, :never] fail( ProcessHelper::InvalidOptionsError, "#{quote_and_join_pair(pair)} options must be one of the following: " + valid_values.map { |v| ":#{v}" }.join(', ') ) unless valid_values.include?(value) end
warn_if_output_may_be_suppressed_on_error(options)
click to toggle source
# File lib/process_helper.rb, line 69 def warn_if_output_may_be_suppressed_on_error(options) return unless options[:puts_output] == :never && options[:include_output_in_exception] == false err_msg = 'WARNING: Check your ProcessHelper options - ' \ ':puts_output is :never, and :include_output_in_exception ' \ 'is false, so all error output will be suppressed if process fails.' $stderr.puts(err_msg) end