Tuesday, April 24, 2012

Automating command line scripts in PHP

I started out creating this class once I finally got fed up with continuously looking up documentation for `expect` (and tcl, the language of `expect` scripts). I was aiming for something with the same functionality as expect but with a more familiar PHP syntax environment. After a few successful attempts I fell onto the expect pecl ( http://pecl.php.net/package/expect ) but seeing the interface exposed, I really didn't care for it either and just decided to continue this one. This is what I came up with followed by an example use case:
# namespace hl5\expect;

/**
 * this is a pure php implementation of something like 'expect'. this is for
 * automating cli applications where the apps block while waiting for user
 * input. In the end, the goal is to automate these input blocking cli apps for
 * example, subversion asking for svn username and password.
 * @since april 23rd, 2012
 * @author shean massey
 */
class proc {
  private $_proc_resource = null;
  private $_cases = array();
  private $_output_text = '';
  private $_pipes = null;

  public function __construct( $process = '' ) {
    if ( $process ) {
      $this->open( $process );
    }
  }

  /**
   * create the internal pipes for reading and writing to the opened process
   */
  public function open( $process ) {
    $descriptors = array(
      0 => array('pipe', 'r'),
      1 => array('pipe', 'w'),
      2 => array('file', '/tmp/expect_errors.log', 'a'),
    );
    $this->_proc_resource = proc_open( $process, $descriptors, $pipes );
    if ( ! is_resource( $this->_proc_resource ) ) {
      throw new \exception('proc_open() failed to create a resource');
    }
    $this->_pipes = $pipes;
    return $this;
  }

  /**
   * close the opened pipes+process and return the return code of that process
   */
  public function close() {
    fclose( $this->_pipes[0] );
    fclose( $this->_pipes[1] );
    $return_code = proc_close( $this->_proc_resource );
    return $return_code;
  }

  /**
   * set the rules, and assoc array where the keys are strings
   * of 'expected text' and the values are closures to be executed
   * once the expected string has been matched
   */
  public function on( array $cases ) {
    $this->_cases = $cases;
    return $this;
  }

  /**
   * write to the opened processes input stream
   */
  public function write( $text ) {
    fwrite( $this->_pipes[0], $text );
    return $this;
  }

  /**
   * write() + newline
   */
  public function writeln( $text ) {
    return $this->write( $text . PHP_EOL );
  }

  /**
   * compare the end of the buffer with $expected_text
   */
  private function _caught( $expected_text ) {
    $expected_length = strlen( $expected_text );
    $buffer_length = strlen( $this->_output_text );
    $search_position = $buffer_length - $expected_length;
    $test_string = substr($this->_output_text,$search_position,$expected_length);
    return ($test_string===$expected_text);
  }

  /**
   * run the script that was previously open()d and apply the expectation rules
   */
  public function run() {
    while ( $char = stream_get_contents( $this->_pipes[1], 1 ) ) {
      $this->_output_text .= $char;
      foreach ( $this->_cases as $expected_text => $closure ) {
        if ( ! $this->_caught( $expected_text ) ) continue;
        $closure();
        $this->_output_text = '';
      }
    }
  }
}
The concept is simple: open a process with proc_open and use the i/o pipes for reading the open processes output and reacting on certain output string by, for example, writing to the open processes input. A very simple use case is for subversion -update which prompts for an svn username and password:
$proc = new proc();
$proc->open('svn update /var/projects/library/');
$proc->on(array(
    'Subversion user name: ' => function() use ( $proc ) {
        $proc->writeln('shean.massey');
    },
    'Subversion password: ' => function() use ( $proc ) {
        $proc->writeln('my_super_secret_password!');
        exit( $proc->close() );
    },
))->run();
This seems pretty straight forward but just in case: I initiate a new proc() object, and open the process 'svn'. I then set 2 rules to react on, first once the script detects 'Subversion user name: ' (output from the svn update) the script sends my subversion username to svn prompt. Then once the script detects the output 'Subversion password: ' the script sends my password the closes returning the return value of the command.
This script will run on Windows! The only modification needed is changing the output file for the errors to a plausible path.

Cheers