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