lib/expectr.rb in expectr-1.1.1 vs lib/expectr.rb in expectr-2.0.0

- old
+ new

@@ -1,13 +1,17 @@ -require 'pty' require 'timeout' require 'thread' require 'io/console' require 'expectr/error' +require 'expectr/errstr' require 'expectr/version' +require 'expectr/child' +require 'expectr/adopt' +require 'expectr/lambda' + # Public: Expectr is an API to the functionality of Expect (see # http://expect.nist.gov) implemented in ruby. # # Expectr contrasts with Ruby's built-in Expect class by avoiding tying in # with the IO class, instead creating a new object entirely to allow for more @@ -32,33 +36,30 @@ class Expectr DEFAULT_TIMEOUT = 30 DEFAULT_FLUSH_BUFFER = true DEFAULT_BUFFER_SIZE = 8192 DEFAULT_CONSTRAIN = false - DEFAULT_FORCE_MATCH = false # Public: Gets/sets the number of seconds a call to Expectr#expect may last attr_accessor :timeout # Public: Gets/sets whether to flush program output to $stdout attr_accessor :flush_buffer # Public: Gets/sets the number of bytes to use for the internal buffer attr_accessor :buffer_size # Public: Gets/sets whether to constrain the buffer to the buffer size attr_accessor :constrain - # Public: Whether to always attempt to match once on calls to Expectr#expect. - attr_accessor :force_match # Public: Returns the PID of the running process attr_reader :pid # Public: Returns the active buffer to match against attr_reader :buffer # Public: Returns the buffer discarded by the latest call to Expectr#expect attr_reader :discard # Public: Initialize a new Expectr object. # Spawns a sub-process and attaches to STDIN and STDOUT for the new process. # - # cmd - A String or File referencing the application to launch + # cmd - A String or File referencing the application to launch (default: '') # args - A Hash used to specify options for the new object (default: {}): # :timeout - Number of seconds that a call to Expectr#expect has # to complete (default: 30) # :flush_buffer - Whether to flush output of the process to the # console (default: true) @@ -66,72 +67,50 @@ # at a time. If :constrain is true, this will be the # maximum size of the internal buffer as well. # (default: 8192) # :constrain - Whether to constrain the internal buffer from the # sub-process to :buffer_size (default: false) - # :force_match - Whether to always attempt to match against the - # internal buffer on a call to Expectr#expect. This - # is relevant following a failed call to - # Expectr#expect, which will leave the update status - # set to false, preventing further matches until more - # output is generated otherwise. (default: false) - def initialize(cmd, args={}) - cmd = cmd.path if cmd.kind_of?(File) - raise ArgumentError, "String or File expected" unless cmd.kind_of?(String) - + # :interface - Interface Object to use when instantiating the new + # Expectr object. (default: Child) + def initialize(cmd = '', args = {}) + setup_instance parse_options(args) - @buffer = '' - @discard = '' - @out_mutex = Mutex.new - @out_update = false - @interact = false - @stdout,@stdin,@pid = PTY.spawn(cmd) - @stdout.winsize = $stdout.winsize if $stdout.tty? - - Thread.new do - process_output + case args[:interface] + when :lambda + interface = call_lambda_interface(args) + when :adopt + interface = call_adopt_interface(args) + else + interface = call_child_interface(cmd) end - Thread.new do - Process.wait @pid - @pid = 0 + interface.init_instance.each do |spec| + ->(name, func) { define_singleton_method(name, func.call) }.call(*spec) end + + Thread.new { output_loop } end # Public: Relinquish control of the running process to the controlling - # terminal, acting as a pass-through for the life of the process. SIGINT - # will be caught and sent to the application as "\C-c". + # terminal, acting as a pass-through for the life of the process (or until + # the leave! method is called). # # args - A Hash used to specify options to be used for interaction (default: # {}): # :flush_buffer - explicitly set @flush_buffer to the value specified # :blocking - Whether to block on this call or allow code # execution to continue (default: false) # # Returns the interaction Thread def interact!(args = {}) - raise ProcessError if @interact - - blocking = args[:blocking] || false - @flush_buffer = args[:flush_buffer].nil? ? true : args[:flush_buffer] - - interact = Thread.new do - env = prepare_interact_environment - input = '' - - while @pid > 0 && @interact - if select([$stdin], nil, nil, 1) - c = $stdin.getc.chr - send c unless c.nil? - end - end - - restore_environment(env) + if @interact + raise(ProcessError, Errstr::ALREADY_INTERACT) end - blocking ? interact.join : interact + @flush_buffer = args[:flush_buffer].nil? ? true : args[:flush_buffer] + args[:blocking] ? interact_thread.join : interact_thread end # Public: Report whether or not current Expectr object is in interact mode # # Returns true or false @@ -144,36 +123,10 @@ # Returns nothing. def leave! @interact=false end - # Public: Kill the running process, raise ProcessError if the pid isn't > 1 - # - # signal - Symbol, String, or Fixnum representing the signal to send to the - # running process. (default: :TERM) - # - # Returns true if the process was successfully killed, false otherwise - def kill!(signal=:TERM) - raise ProcessError unless @pid > 0 - (Process::kill(signal.to_sym, @pid) == 1) - end - - # Public: Send input to the active process - # - # str - String to be sent to the active process - # - # Returns nothing. - # Raises Expectr::ProcessError if the process isn't running - def send(str) - begin - @stdin.syswrite str - rescue Errno::EIO #Application went away. - @pid = 0 - end - raise Expectr::ProcessError unless @pid > 0 - end - # Public: Wraps Expectr#send, appending a newline to the end of the string # # str - String to be sent to the active process (default: '') # # Returns nothing. @@ -182,11 +135,14 @@ end # Public: Begin a countdown and search for a given String or Regexp in the # output buffer. # - # pattern - String or Regexp representing what we want to find + # pattern - Object String or Regexp representing pattern for which to + # search, or a Hash containing pattern -> Proc mappings to be + # used in cases where multiple potential patterns should map + # to distinct actions. # recoverable - Denotes whether failing to match the pattern should cause the # method to raise an exception (default: false) # # Examples # @@ -200,64 +156,85 @@ # exp.expect(/not there/) # # Raises Timeout::Error # # exp.expect(/not there/, true) # # => nil + # + # hash = { "First possibility" => -> { puts "option a" }, + # "Second possibility" => -> { puts "option b" }, + # default: => -> { puts "called on timeout" } } + # exp.expect(hash) # # Returns a MatchData object once a match is found if no block is given # Yields the MatchData object representing the match # Raises TypeError if something other than a String or Regexp is given # Raises Timeout::Error if a match isn't found in time, unless recoverable def expect(pattern, recoverable = false) + return expect_procmap(pattern) if pattern.is_a?(Hash) + match = nil - @out_update ||= @force_match - pattern = Regexp.new(Regexp.quote(pattern)) if pattern.kind_of?(String) - unless pattern.kind_of?(Regexp) - raise TypeError, "Pattern class should be String or Regexp" + pattern = Regexp.new(Regexp.quote(pattern)) if pattern.is_a?(String) + unless pattern.is_a?(Regexp) + raise(TypeError, Errstr::EXPECT_WRONG_TYPE) end - begin - Timeout::timeout(@timeout) do - match = check_match(pattern) - end + match = watch_match(pattern, recoverable) + block_given? ? yield(match) : match + end - @out_mutex.synchronize do - @discard = @buffer[0..match.begin(0)-1] - @buffer = @buffer[match.end(0)..-1] - @out_update = true + # Public: Begin a countdown and search for any of multiple possible patterns, + # performing designated actions upon success/failure. + # + # pattern_map - Hash containing mappings between Strings or Regexps and + # procedure objects. Additionally, an optional action, + # designated by :default or :timeout may be provided to specify + # an action to take upon failure. + # + # Examples + # + # exp.expect_procmap({ + # "option 1" => -> { puts "action 1" }, + # /option 2/ => -> { puts "action 2" }, + # :default => -> { puts "default" } + # }) + # + # Calls the procedure associated with the pattern provided. + def expect_procmap(pattern_map) + pattern_map, pattern, recoverable = process_procmap(pattern_map) + match = nil + + match = watch_match(pattern, recoverable) + + pattern_map.each do |s,p| + if s.is_a?(Regexp) + return p.call if s.match(match.to_s) end - rescue Timeout::Error => details - raise details unless recoverable end - block_given? ? yield(match) : match + pattern_map[:default].call unless pattern_map[:default].nil? + pattern_map[:timeout].call unless pattern_map[:timeout].nil? + nil end # Public: Clear output buffer # # Returns nothing. def clear_buffer! @out_mutex.synchronize do - @buffer = '' - @out_update = false + @buffer.clear end end - # Public: Return the child's window size. - # - # Returns a two-element array (same as IO#winsize). - def winsize - @stdout.winsize - end + private # Internal: Print buffer to $stdout if @flush_buffer is true # # buf - String to be printed to $stdout # # Returns nothing. def print_buffer(buf) - print buf if @flush_buffer + $stdout.print buf if @flush_buffer $stdout.flush unless $stdout.sync end # Internal: Encode a String twice to force UTF-8 encoding, dropping # problematic characters in the process. @@ -268,12 +245,10 @@ def force_utf8(buf) return buf if buf.valid_encoding? buf.force_encoding('ISO-8859-1').encode('UTF-8', 'UTF-8', replace: nil) end - private - # Internal: Determine values of instance options and set instance variables # appropriately, allowing for default values where nothing is passed. # # args - A Hash used to specify options for the new object (default: {}): # :timeout - Number of seconds that a call to Expectr#expect has @@ -283,111 +258,170 @@ # :buffer_size - Number of bytes to attempt to read from sub-process # at a time. If :constrain is true, this will be the # maximum size of the internal buffer as well. # :constrain - Whether to constrain the internal buffer from the # sub-process to :buffer_size. - # :force_match - Whether to always attempt to match against the - # internal buffer on a call to Expectr#expect. This - # is relevant following a failed call to - # Expectr#expect, which will leave the update status - # set to false, preventing further matches until more - # output is generated otherwise. # # Returns nothing. def parse_options(args) @timeout = args[:timeout] || DEFAULT_TIMEOUT @buffer_size = args[:buffer_size] || DEFAULT_BUFFER_SIZE @constrain = args[:constrain] || DEFAULT_CONSTRAIN - @force_match = args[:force_match] || DEFAULT_FORCE_MATCH @flush_buffer = args[:flush_buffer] @flush_buffer = DEFAULT_FLUSH_BUFFER if @flush_buffer.nil? end - # Internal: Read from the process's stdout. Force UTF-8 encoding, append to - # the internal buffer, and print to $stdout if appropriate. + # Internal: Initialize instance variables to their default values. + # + # Returns nothing. + def setup_instance + @buffer = '' + @discard = '' + @thread = nil + @out_mutex = Mutex.new + @interact = false + end + + # Internal: Handle data from the interface, forcing UTF-8 encoding, appending + # it to the internal buffer, and printing it to $stdout if appropriate. # # Returns nothing. - def process_output - while @pid > 0 - unless select([@stdout], nil, nil, @timeout).nil? - buf = '' + def process_output(buf) + force_utf8(buf) + print_buffer(buf) - begin - @stdout.sysread(@buffer_size, buf) - rescue Errno::EIO #Application went away. - @pid = 0 - return - end + @out_mutex.synchronize do + @buffer << buf + if @constrain && @buffer.length > @buffer_size + @buffer = @buffer[-@buffer_size..-1] + end + @thread.wakeup if @thread + end + end - print_buffer(force_utf8(buf)) - - @out_mutex.synchronize do - @buffer << buf - if @constrain && @buffer.length > @buffer_size - @buffer = @buffer[-@buffer_size..-1] - end - @out_update = true - end + # Internal: Check for a match against a given pattern until a match is found. + # This method should be wrapped in a Timeout block or otherwise have some + # mechanism to break out of the loop. + # + # pattern - String or Regexp object containing the pattern for which to + # watch. + # + # Returns a MatchData object containing the match found. + def check_match(pattern) + match = nil + @thread = Thread.current + while match.nil? + @out_mutex.synchronize do + match = pattern.match(@buffer) + @out_mutex.sleep if match.nil? end end + match + ensure + @thread = nil end - # Internal: Prepare environment for interact mode, saving original - # environment parameters. - # - # Returns a Hash object with two keys: :tty containing original tty - # information and :sig containing signal handlers which have been replaced. - def prepare_interact_environment - env = {sig: {}} - # Save our old tty settings and set up our new environment - env[:tty] = `stty -g` - `stty -icanon min 1 time 0 -echo` + # Internal: Call the Child Interface to instantiate the Expectr object. + # + # cmd - String or File object referencing the command to be run. + # + # Returns the Interface object. + def call_child_interface(cmd) + interface = Expectr::Child.new(cmd) + @stdin = interface.stdin + @stdout = interface.stdout + @pid = interface.pid - # SIGINT should be sent along to the process. - env[:sig]['INT'] = trap 'INT' do - send "\C-c" + Thread.new do + Process.wait @pid + @pid = 0 end - # SIGTSTP should be sent along to the process as well. - env[:sig]['TSTP'] = trap 'TSTP' do - send "\C-z" - end + interface + end - # SIGWINCH should trigger an update to the child process - env[:sig]['WINCH'] = trap 'WINCH' do - @stdout.winsize = $stdout.winsize - end + # Internal: Call the Lambda Interface to instantiate the Expectr object. + # + # args - Arguments hash passed per #initialize. + # + # Returns the Interface object. + def call_lambda_interface(args) + interface = Expectr::Lambda.new(args[:reader], args[:writer]) + @pid = -1 + @reader = interface.reader + @writer = interface.writer - @interact = true - env + interface end - # Internal: Restore environment post interact mode from saved parameters. - # - # Returns nothing. - def restore_environment(env) - env[:sig].each_key do |sig| - trap sig, env[:sig][sig] + # Internal: Call the Adopt Interface to instantiate the Expectr object. + # + # args - Arguments hash passed per #initialize. + # + # Returns the Interface object. + def call_adopt_interface(args) + interface = Expectr::Adopt.new(args[:stdin], args[:stdout]) + @stdin = interface.stdin + @stdout = interface.stdout + @pid = args[:pid] || 1 + + if @pid > 0 + Thread.new do + Process.wait @pid + @pid = 0 + end end - `stty #{env[:tty]}` - @interact = false + + interface end - # Internal: Check for a match against a given pattern until a match is found. - # This method should be wrapped in a Timeout block or otherwise have some - # mechanism to break out of the loop. + # Internal: Watch for a match within the timeout period. + # + # pattern - String or Regexp object containing the pattern for which to + # watch. + # recoverable - Boolean denoting whether a failure to find a match should be + # considered fatal. # - # Returns a MatchData object containing the match found. - def check_match(pattern) + # Returns a MatchData object if a match was found, or else nil. + # Raises Timeout::Error if no match is found and recoverable is false. + def watch_match(pattern, recoverable) match = nil - while match.nil? - if @out_update - @out_mutex.synchronize do - match = pattern.match(@buffer) - @out_update = false - end - end - sleep 0.1 + + Timeout::timeout(@timeout) do + match = check_match(pattern) end + + @out_mutex.synchronize do + @discard = @buffer[0..match.begin(0)-1] + @buffer = @buffer[match.end(0)..-1] + end + match + rescue Timeout::Error => details + raise(Timeout::Error, details) unless recoverable + nil + end + + # Internal: Process a pattern to procedure mapping, producing a sanitized + # Hash, a unified Regexp and a boolean denoting whether an Exception should + # be raised upon timeout. + # + # pattern_map - A Hash containing mappings between patterns designated by + # either strings or Regexp objects, to procedures. Optionally, + # either :default or :timeout may be mapped to a procedure in + # order to designate an action to take upon timeout. + # + # Returns a Hash, Regexp and boolean object. + def process_procmap(pattern_map) + pattern_map = pattern_map.reduce({}) do |c,e| + c.merge((e[0].is_a?(Symbol) ? e[0] : Regexp.new(e[0].to_s)) => e[1]) + end + pattern = pattern_map.keys.reduce("") do |c,e| + e.is_a?(Regexp) ? c + "(#{e.source})|" : c + end + pattern = Regexp.new(pattern.gsub(/\|$/, '')) + recoverable = pattern_map.keys.include?(:default) + recoverable ||= pattern_map.keys.include?(:timeout) + + return pattern_map, pattern, recoverable end end