Monday, May 14, 2007

Developing PHP applications for the command line

So you have a problem. You've got your entire application neatly made, and you've glued it to a web interface. Unfortunately, what can be done by simply typing in a few details comes with a lot of overhead when you add in html - you have to render everything, output forms, do security so people don't get at your sensitive administration tools...

Ugh. Hard work, isn't it. Many of these things can be fixed with a good command line application. You can then rely on server-level permissions, you can quickly type, and you can set things up to run with cron jobs.

So what's out there on PHP in the command line? A few articles with Hello world? What if you want something more?

This is my solution - it's not for everyone and has its limitations.

CommandLineApplication.php

<?php
require_once 'Console/Getopt.php';
require_once 'Console/Table.php';
require_once 'Console/ProgressBar.php';

/**
* A generic class for building command line applications on top of
*/
class CommandLineApplication {
protected $path;
protected $options;

private static $stdin;
private static $stdout;
private static $stderr;

public static function main(CommandLineApplication $application) {
$options = $application->parseArguments();

$application->dispatch($options);
}

public function defaults() {
return array();
}

public function getConsoleOptions() {
return array(array(), array());
}

public function parseArguments() {
global $args;

$con = new Console_Getopt;
$args = $con->readPHPArgv();

list($shortOptions, $longOptions) = $this->getConsoleOptions();

try {
$lines = $con->getopt($args, $shortOptions, $longOptions);
} catch (PearWrapperException $pwe) {
die($pwe->ggetMessage());
}


$defaults = $this->defaults();

$found = array();

$options = $lines[0];

foreach ($defaults as $key => $value) {
foreach ($options as $option) {
if ($option[0] == $key) {
$found[$key] = true;
}
}
}

foreach ($defaults as $key => $value) {
if (empty($found[$key])) {
array_unshift($options, array($key, $value));
}
}

$this->setOptions($options);

return $this->getOptions();
}

public function setOption($name, $value) {
$this->options[$name] = $value;
}
public function getOption($name) {
return $this->options[$name];
}

public function className($name) {
return sprintf('%s_Command_%s', get_class($this), $name);
}

public function resolveCommand($name) {
return $name . ".php";
}

public function dispatch($options) {

foreach ($options as $option) {
$name = str_replace('--','',$option[0]);

$file = $this->resolveCommand($name);
if (file_exists($file)) {
$class_name = $this->className($name);
require_once $file;
$class = new $class_name();
$class->handle($option, $this);
} else {
$this->setOption($name, $option[1]);
}
}
}

public function setOptions($options) {
$this->options = $options;
}

public function getOptions() {
return $this->options;
}

/**
* Singleton method to open an input stream.
*/
public static function inputStream() {
if (!isset(self::$stdin)) {
self::$stdin = fopen('php://stdin', 'r');
}
return self::$stdin;
}

public static function outputStream() {
if (!isset(self::$stdout)) {
self::$stdout = fopen('php://stdout', 'w+');

}
return self::$stdout;
}

public static function errorStream() {
if (!isset(self::$stderr)) {
self::$stderr = fopen('php://stderr', 'w+');
}
return self::$stderr;
}

public static function prompt($text) {
$stdin = self::inputStream();
$stdout = self::outputStream();


$text .= "\n";
if (PEAR_OS != 'Windows') {
$text = Console_Color::convert("%g" . $text . "%n");
}

fwrite($stdout, $text);

//[Yn]
$pattern = '/\[(.*)\]/';
$matches = array();
if (preg_match($pattern, $text, $matches) == 0) {
return null;
}

$choices = str_split(strtolower($matches[1]));
while (($choice = fread($stdin,1)) !== FALSE) {
if (in_array($choice, $choices)) {
return $choice;
} else {
fwrite($stdout, $text);
}
}

return null;
}
}


To use it, you simply extend it

main.php

<?php
require_once 'CommandLineApplication.php';

class AccountingApplication extends CommandLineApplication {

public function getConsoleOptions() {
$longOptions = array(
'help==',
'invoice',
'path=',
'type=',
'id=',
'start=',
'end=',
'valfirm=',
'client='
);

$shortOptions = array();

return array($shortOptions, $longOptions);
}

public function defaults() {
$defaults = array();
$defaults['--type'] = 'recipient';
$defaults['--start']= '*';
$defaults['--end'] = '*';
$defaults['--valfirm'] = '*';

return $defaults;
}

public function className($name) {
return 'Accounting_Command_' . $name;
}

public function resolveCommand($name) {
return dirname(__FILE__) . "/commands/" . $name . ".php";
}
}

class AccountingException extends Exception {}

AccountingApplication::main(new AccountingApplication());


and then, as needed, implement invididual commands

commands/invoice.php

<?php
define('DATE_CALC_FORMAT', "%Y-%m-%d");

require_once 'Console/Table.php';
require_once 'Console/Color.php';
require_once 'Date/Calc.php';


class Accounting_Command_Invoice {
public function handle($options, AccountingApplication &$application) {
$start = $application->getOption('start');
$end = $application->getOption('end');

if (empty($start)) {
$start = strtotime(Date_Calc::beginOfPrevMonth());
}

if (empty($end)) {
$end = strtotime(Date_Calc::beginOfMonth());
}

$cl_id = $application->getOption('client');
$valfirm_id = $application->getOption('valfirm');


if ($valfirm_id == '*') {
$valfirm = new ValuationFirmOrganisation();
$valfirm->setName('All valuation firms');
} else {
$valfirm = new ValuationFirmOrganisation();
}

$client = new ClientOrganisation($cl_id);

$invoices = new RecipientInvoice();
printf("Generating\n\tfrom\t%s\n\tto\t%s\n\tfor\t%s\n\tby\t%s\n\n", date("Y-m-d H:i:s", $start), date("Y-m-d H:i:s", $end), $client->getName(), $valfirm->getName());
$paths = $invoices->generateInvoices($start, $end, $valfirm->getID(), $client->getID());

if (empty($paths)) {
print "No files generated\n";
} else {
print "Generated:\n";
foreach ($paths as $path) {
print "\t" . $path . "\n";
}
}
}
}


Let's break this down into it's individual parts.

You run it as follows
php main.php --start=2007-01-12 --client=8 --invoice

First, this instantiates a new AccountingApplication
AccountingApplication::main(new AccountingApplication());

This then parses the arguments and dispatches them.

public static function main(CommandLineApplication $application) {
$options = $application->parseArguments();

$application->dispatch($options);
}


Argument parsing is done by Console_GetOpt. In your child class, you've defined getConsoleOptions() and defaults(). defaults() are the default arguments, and getConsoleOptions() is the available commands.

This assumes console_getopt will throw an exception - if you are using PEAR::isError(), you'll want to check the return value.


public function parseArguments() {
global $args;

$con = new Console_Getopt;
$args = $con->readPHPArgv();

list($shortOptions, $longOptions) = $this->getConsoleOptions();

try {
$lines = $con->getopt($args, $shortOptions, $longOptions);
} catch (Exception $e) {
die($e->getMessage());
}


$defaults = $this->defaults();

$found = array();

$options = $lines[0];

foreach ($defaults as $key => $value) {
foreach ($options as $option) {
if ($option[0] == $key) {
$found[$key] = true;
}
}
}

foreach ($defaults as $key => $value) {
if (empty($found[$key])) {
array_unshift($options, array($key, $value));
}
}

$this->setOptions($options);

return $this->getOptions();
}


Next, we dispatch. To do this, we iterate over the command line options.

If something has been entered, first we try to resolve the command name to a file - pretty much an __autoload(). This is resolveCommand().

If we can't match anything - there are no special handlers - we just set it as an option for the application.

public function dispatch($options) {

foreach ($options as $option) {
$name = str_replace('--','',$option[0]);

$file = $this->resolveCommand($name);
if (file_exists($file)) {
$class_name = $this->className($name);
require_once $file;
$class = new $class_name();
$class->handle($option, $this);
} else {
$this->setOption($name, $option[1]);
}
}
}


Finally, we've dispatched to the individual command. It's a good idea to enforce the 'command that does whatever you want' to be put at the end - ie, php main.php --option=value --action. This ensures that all options have been set before the guts of the application starts to work.

In this case, we've made an invoice generator. ValuationFirmOrganisation and ClientOrganisation are just models, more or less; and RecipientInvoices is a generator for the actual invoice files. Previously what is done in Accounting_Command_Invoice would have been tied to processing form information from a webpage, and executing the below code.


<?php
define('DATE_CALC_FORMAT', "%Y-%m-%d");

require_once 'Console/Table.php';
require_once 'Console/Color.php';
require_once 'Date/Calc.php';


class Accounting_Command_Invoice {
public function handle($options, AccountingApplication &$application) {
$start = $application->getOption('start');
$end = $application->getOption('end');

if (empty($start)) {
$start = strtotime(Date_Calc::beginOfPrevMonth());
}

if (empty($end)) {
$end = strtotime(Date_Calc::beginOfMonth());
}

$cl_id = $application->getOption('client');
$valfirm_id = $application->getOption('valfirm');


if ($valfirm_id == '*') {
$valfirm = new ValuationFirmOrganisation();
$valfirm->setName('All valuation firms');
} else {
$valfirm = new ValuationFirmOrganisation();
}

$client = new ClientOrganisation($cl_id);

$invoices = new RecipientInvoice();
printf("Generating\n\tfrom\t%s\n\tto\t%s\n\tfor\t%s\n\tby\t%s\n\n", date("Y-m-d H:i:s", $start), date("Y-m-d H:i:s", $end), $client->getName(), $valfirm->getName());
$paths = $invoices->generateInvoices($start, $end, $valfirm->getID(), $client->getID());

if (empty($paths)) {
print "No files generated\n";
} else {
print "Generated:\n";
foreach ($paths as $path) {
print "\t" . $path . "\n";
}
}
}
}


So what's next? Say you need to process user input. You can use the handy helper method CommandLineApplication::prompt(). You pass in a string, and in square brackets, you put in the 1 character options.

$result = CommandLineApplication::prompt("Do you want to continue? [yn]")

When it finds a match, it returns it into $result for you.

What else? Got an error? Output it to stderr.


$stderr = CommandLineApplication::errorStream();

fwrite($stderr, "An error occured!");


Got a long wait? This is where Console_ProgressBar really helps you (from the pear examples for this package).

require_once 'Console/ProgressBar.php';

print "This will display a very simple bar:\n";
$bar = new Console_ProgressBar('%bar%', '=', ' ', 76, 3);
for ($i = 0; $i <= 3; $i++) {
$bar->update($i);
sleep(1);
}
print "\n";


Got tabular information to display? Console_Table rocks here.

$stdout = CommandLineApplication::outputStream();

$data = array();
foreach ($clients as $id) {
$client = new ClientOrganisation($id);
$data[] = array(count($client->users), $client->getName());
}


$table = new Console_Table();
fwrite($stdout, $table->fromArray(array('Total users', 'Client'), $data));


All in all, you've now got a pretty handy little kit for building quick command line applications.

No comments: