lib/expectr.rb in expectr-0.9.1 vs lib/expectr.rb in expectr-1.0.0

- old
+ new

@@ -1,290 +1,287 @@ -# = expectr.rb -# -# Copyright (c) Chris Wuest <chris@chriswuest.com> -# Expectr is freely distributable under the terms of an MIT-style license. -# See COPYING or http://www.opensource.org/licenses/mit-license.php. - -begin - require 'pty' -rescue LoadError - require 'popen4' -end - +require 'pty' require 'timeout' require 'thread' -# Fixes specifically for Ruby 1.8 -if RUBY_VERSION =~ /^1.8/ - # Enforcing encoding is not needed in 1.8 (probably.) So, we'll define - # String#encode! to do nothing, for interoperability. - class String #:nodoc: - def encode!(encoding) - end - end +require 'expectr/error' - # In Ruby 1.8, we want to ignore SIGCHLD. This is for two reasons: - # * SIGCHLD will be sent (and cause exceptions) for every Expectr object - # created - # * As John Carter documented in his RExpect library, calls to files which - # do not exist can cause odd and unexpected behavior. - trap 'CHLD', Proc.new { nil } -end - -# == Description -# Expectr is an implementation of the Expect library in ruby (see -# http://expect.nist.gov). +# 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 IO and instead creating a new object entirely to allow for more -# fine-grained control over the execution and display of the program being +# with the IO class, instead creating a new object entirely to allow for more +# grainular control over the execution and display of the program being # run. # -# == Examples -# === Simple task automation +# Examples # -# Connect via telnet to remote.example.com, run my_command, and return the -# output +# # SSH Login to another machine +# exp = Expectr.new('ssh user@example.com') +# exp.expect("Password:") +# exp.send('password') +# exp.interact!(blocking: true) # -# exp = Expectr.new "telnet remote.example.com" -# exp.expect "username:" -# exp.send "example\r" -# exp.expect "password:" -# exp.send "my_password\r" -# exp.expect "%" -# exp.send "my_command\r" -# exp.expect "%" -# exp.send "logout" -# -# output = exp.discard -# -# === Interactive control -# Silently connect via ssh to remote.example.com, log in automatically, then -# relinquish control to the user. Expect slow networking, so increase -# timeout. -# -# exp = Expectr.new "ssh remote.example.com", :timeout=>45, :flush_buffer=>false -# -# match = exp.expect /password|yes\/no/ -# case match.to_s -# when /password/ -# exp.send "my_password\r" -# when /yes\/no/ -# exp.send "yes\r" -# exp.expect /password/ -# exp.send "my_password\r" -# else -# puts "Cannot connect to remote.example.com!" -# die +# # See if a web server is running on the local host, react accordingly +# exp = Expectr.new('netstat -ntl|grep ":80 " && echo "WEB"', timeout: 1) +# if exp.expeect("WEB") +# # Do stuff if we see 'WEB' in the output +# else +# # Do other stuff # end -# -# exp.expect "$" -# exp.interact -# class Expectr - # Amount of time in seconds a call to +expect+ may last (default 30) - attr_accessor :timeout - # Size of buffer in bytes to attempt to read in at once (default 8 KiB) - attr_accessor :buffer_size - # Whether to flush program output to STDOUT (default true) - attr_accessor :flush_buffer - # PID of running process - attr_reader :pid - # Active buffer to match against - attr_reader :buffer - # Buffer passed since last +expect+ match - attr_reader :discard + # Public: Gets/sets the number of seconds a call to Expectr#expect may last + attr_accessor :timeout + # 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: Gets/sets whether to flush program output to STDOUT + attr_accessor :flush_buffer + # 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 - # - # === Synopsis - # - # Expectr.new(cmd, args) - # - # === Arguments - # +cmd+:: - # Command to be executed (String or File) - # +args+:: - # Hash of modifiers for Expectr. Meaningful values are: - # * :buffer_size:: - # Amount of data to read at a time. Default 8 KiB - # * :flush_buffer:: - # Flush buffer to STDOUT during execution? Default true - # * :timeout:: - # Timeout in seconds for each +expect+ call. Default 30 - # - # === Description - # - # Spawn +cmd+ and attach to STDIN and STDOUT for new process. Fall back - # to using Open4 if PTY is not present (this is the case on Windows - # implementations of ruby. + # Public: Initialize a new Expectr object. + # Spawns a sub-process and attaches to STDIN and STDOUT for the new process. # - def initialize(cmd, args={}) - raise ArgumentError, "String or File expected, was given #{cmd.class}" unless cmd.kind_of? String or cmd.kind_of? File - cmd = cmd.path if cmd.kind_of? File + # cmd - A String or File referencing the application to launch + # 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) + # :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. + # (default: 8192) + # :constrain - Whether to constrain the internal buffer from the + # sub-process to :buffer_size (default: false) + def initialize(cmd, args={}) + unless cmd.kind_of? String or cmd.kind_of? File + raise ArgumentError, "String or File expected" + end - args[0] = {} unless args[0] - @buffer = String.new - @discard = String.new - @timeout = args[:timeout] || 30 - @flush_buffer = args[:flush_buffer].nil? ? true : args[:flush_buffer] - @buffer_size = args[:buffer_size] || 8192 - @out_mutex = Mutex.new - @out_update = false + cmd = cmd.path if cmd.kind_of? File - [@buffer, @discard].each {|x| x.encode! "UTF-8" } + @buffer = ''.encode("UTF-8") + @discard = ''.encode("UTF-8") - if defined? PTY - @stdout,@stdin,@pid = PTY.spawn cmd - else - cmd << " 2>&1" if cmd[/2\s*>/].nil? - @pid, @stdin, @stdout, stderr = Open4::popen4 cmd - end + @timeout = args[:timeout] || 30 + @flush_buffer = args[:flush_buffer].nil? ? true : args[:flush_buffer] + @buffer_size = args[:buffer_size] || 8192 + @constrain = args[:constrain] || false - Thread.new do - while @pid > 0 - unless select([@stdout], nil, nil, @timeout).nil? - buf = '' + @out_mutex = Mutex.new + @out_update = false + @interact = false - begin - @stdout.sysread(@buffer_size, buf) - rescue Errno::EIO #Application went away. - @pid = 0 - break - end + @stdout,@stdin,@pid = PTY.spawn(cmd) - buf.encode! "UTF-8" - print_buffer buf + Thread.new do + while @pid > 0 + unless select([@stdout], nil, nil, @timeout).nil? + buf = ''.encode("UTF-8") - @out_mutex.synchronize do - @buffer << buf - @out_update = true - end - end - end - end + begin + @stdout.sysread(@buffer_size, buf) + rescue Errno::EIO #Application went away. + @pid = 0 + break + end - Thread.new do - Process.wait @pid - @pid = 0 - end - end + print_buffer(buf) - # - # Clear output buffer - # - def clear_buffer - @out_mutex.synchronize do - @buffer = '' - @out_update = false - end - end + @out_mutex.synchronize do + @buffer << buf + if @buffer.length > @buffer_size && @constrain + @buffer = @buffer[-@buffer_size..-1] + end + @out_update = true + end + end + end + end - # - # === Synopsis - # - # Expectr#interact - # - # === Description - # - # Relinquish control of the running process to the controlling terminal, - # acting simply as a pass-through for the life of the process. - # - # Interrupts should be caught and sent to the application. - # - def interact - oldtrap = trap 'INT' do - send "\C-c" - end + Thread.new do + Process.wait @pid + @pid = 0 + end + end - @flush_buffer = true - old_tty = `stty -g` - `stty -icanon min 1 time 0 -echo` - - in_thread = Thread.new do - input = '' - while @pid > 0 - if select([STDIN], nil, nil, 1) - send STDIN.getc.chr - end - end - 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". + # + # 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 - in_thread.join - trap 'INT', oldtrap - `stty #{old_tty}` - return nil - end - alias :interact! :interact + blocking = args[:blocking] || false + @flush_buffer = args[:flush_buffer].nil? ? true : args[:flush_buffer] + @interact = true - # - # Send +str+ to application - # - def send(str) - begin - @stdin.syswrite str - rescue Errno::EIO #Application went away. - @pid = 0 - end - raise ArgumentError unless @pid > 0 - end + # Save our old tty settings and set up our new environment + old_tty = `stty -g` + `stty -icanon min 1 time 0 -echo` - # - # === Synopsis - # - # Expectr#expect /regexp/, recoverable=false - # Expectr#expect "String", recoverable=true - # - # === Arguments - # - # +pattern+:: - # String or regexp to match against - # +recoverable+:: - # Determines if execution can continue after a timeout - # - # === Description - # - # Wait +timeout+ seconds to match +pattern+ in +buffer+. If timeout is - # reached, raise an error unless +recoverable+ is true. - # - def expect(pattern, recoverable = false) - match = nil + # SIGINT should be set along to the program + oldtrap = trap 'INT' do + send "\C-c" + end + + interact = Thread.new do + input = ''.encode("UTF-8") + while @pid > 0 && @interact + if select([STDIN], nil, nil, 1) + c = STDIN.getc.chr + send c unless c.nil? + end + end - case pattern - when String - pattern = Regexp.new(Regexp.quote(pattern)) - when Regexp - else raise TypeError, "Pattern class should be String or Regexp, passed: #{pattern.class}" - end + trap 'INT', oldtrap + `stty #{old_tty}` + @interact = false + end - begin - Timeout::timeout(@timeout) do - while match.nil? - if @out_update - @out_mutex.synchronize do - match = pattern.match @buffer - @out_update = false - end - end - sleep 0.1 - end - end + blocking ? interact.join : interact + end - @out_mutex.synchronize do - @discard = @buffer[0..match.begin(0)-1] - @buffer = @buffer[match.end(0)..-1] - @out_update = true - end - rescue Timeout::Error => details - raise details unless recoverable - end + # Public: Report whether or not current Expectr object is in interact mode + # + # Returns true or false + def interact? + @interact + end - return match - end + # Public: Cause the current Expectr object to drop out of interact mode + # + # Returns nothing. + def leave! + @interact=false + end - # - # Print buffer to STDOUT only if +flush_buffer+ is true - # - def print_buffer(buf) - print buf if @flush_buffer - STDOUT.flush - 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. + def puts(str = '') + send str + "\n" + 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 + # recoverable - Denotes whether failing to match the pattern should cause the + # method to raise an exception (default: false) + # + # Examples + # + # exp.expect("this should exist") + # # => MatchData + # + # exp.expect("this should exist") do + # # ... + # end + # + # exp.expect(/not there/) + # # Raises Timeout::Error + # + # exp.expect(/not there/, true) + # # => nil + # + # 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) + match = nil + + case pattern + when String + pattern = Regexp.new(Regexp.quote(pattern)) + when Regexp + else + raise TypeError, "Pattern class should be String or Regexp" + end + + begin + Timeout::timeout(@timeout) do + while match.nil? + if @out_update + @out_mutex.synchronize do + match = pattern.match @buffer + @out_update = false + end + end + sleep 0.1 + end + end + + @out_mutex.synchronize do + @discard = @buffer[0..match.begin(0)-1] + @buffer = @buffer[match.end(0)..-1] + @out_update = true + end + rescue Timeout::Error => details + raise details unless recoverable + end + + block_given? ? yield(match) : match + end + + # Public: Clear output buffer + # + # Returns nothing. + def clear_buffer! + @out_mutex.synchronize do + @buffer = ''.encode("UTF-8") + @out_update = false + end + end + + # 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.flush unless STDOUT.sync + end end