Sunday, August 15, 2010

Controlling a command-line interpreter in Ruby

There seem to be a lot of posts to mailing lists, forums, etc in regards to controlling a child process in Ruby via STDIN and STDOUT. Most of the discussion (with this notable exception) ends with "use open3!" or "use open4!".

Anyone who has ever tried to control (i.e., tried to send more than one command to) an interpreter using either of these recognise their shortcomings immediately: the child process STDOUT cannot be read until STDIN has been closed.

Fortunately, the pty module (helpfully mentioned in the notable exception) allows the interpreter to be controlled properly as long as the OS supports psuedo-terminals -- that is, as long as it is a UNIX-like OS (i.e. Linux, OS X, *BSD... basically every desktop/server OS but Windows).

It is a bit tricky to get working well, due to the problem of not knowing for sure whether the child process is preparing more data to write to STDOUT, or is waiting for another command on STDIN.

The following implementation wraps the praat speech analysis software. It uses the praat prompt ('Praat > ') to determine when the child process is ready for more input (i.e., when it can stop reading from STDOUT).

#!/usr/bin/env ruby

require 'pty'

module Praat

  class Interpreter
    attr_reader :stdout, :stdin, :pid

    PROMPT='Praat > '

    def initialize(program='praat')
      @stdout, @stdin, @pid = PTY.spawn( 'praat', '-' )

      # Read initial prompt from pipe
      read_until_prompt

      if block_given?
        yield self
        @stdin.close
        @stdout.close
        @stdin = @stdout = @pid = nil
      end

    end

    def read_until_prompt
      outbuf = buf = ''

      # Read from child STDOUT until > 0 bytes have been read
      # (i.e. wait for child process to finish reading input)
      while buf.length == 0
        begin
          IO.select([@stdout])  # block until child process is ready
          @stdout.read_nonblock( 1024, buf )
        rescue Exception => e
          buf = ''              # READ failure! Try again.
        end
      end

      # Read from child STDOUT until 0 bytes are read or a line ending in a
      # prompt (i.e. next input prompt) was encountered.
      while buf.length > 0
        outbuf << buf

        # complete read if next input prompt is encountered
        break if outbuf =~ /#{PROMPT}$/

        begin
          buf = ''
          @stdout.read_nonblock( 1024, buf )
        rescue Errno::EAGAIN => e
          IO.select([@stdout])  # block until child process is ready
          retry
        rescue Exception => e
          buf=''                # READ failure. Exit loop.
        end

      end

      # Return output of interpreter as an array of lines
      return outbuf.split("\n").each { |x| x.chomp! }
    end

    # Send a command to the interpreter. Returns an array of the output.
    # If include_prompts is true, lines beginning with a prompt will NOT be
    # stripped from the output.
    def send( command, include_prompts=false )
      @stdin.write(command + "\n")

      outbuf = read_until_prompt

      # Ignore all ECHOed lines before the first (input) prompt
      first_prompt = outbuf.find_index { |x| x =~ /^#{PROMPT}/ }
      result = outbuf.slice(first_prompt, outbuf.length-first_prompt)

      # Return full results, or the results with prompt lines removed
      result = result.select {|x| x !~ /^#{PROMPT}/} if not include_prompts

      yield result if block_given?

      return result
    end

  end

end

if __FILE__ == $0

  puts 'Testing block implementation'
  Praat::Interpreter.new() { |p| puts p.send('echo BLOCK TEST') }

  Praat::Interpreter.new() do |p|
    p.send('echo Full Output', true).each { |line| puts "\t" + line }
  end

  puts 'Testing object implementation'
  praat = Praat::Interpreter.new()

  lines = []
  lines.concat praat.send('echo OBJ TEST 1' )
  lines.concat praat.send('echo OBJ TEST 2' )
  lines.concat praat.send('echo OBJ TEST 3' )

  puts lines.inspect
end

1 comment:

  1. with open4 stdout.read will indeed block until stdin.close, but you could still have used stdout.readline to interact with process, explanation here: https://www.ruby-forum.com/topic/1452556#991753

    ReplyDelete