in this series, we've been looking at how to write command line applications in php using macrame. in the previous installment, we covered the basic structure of macrame scripts, getting user input both as text and from interactive menus, parsing command-line arguments, and styling our output text.
the sample application we're building is a script that fetches a list of a mastodon user's followers and outputs the data in a nicely-formatted table. it looks like this:
the flyover
in this installment we'll be looking at:
- outputting array data as a nicely-formatted ascii table
- running a function in the background while we show users an animated spinner
- writing safely to files
- paging long output
- basic notice level output
before we start, the (skipable) mastodon stuff
our example script scrapes a list of one user's followers from a mastodon instance. this, of course, requires us to make some calls to the mastodon api.
there are the two functions our script has that do this:
-
mastodonUserId()
: accepts a mastodon user's username and calls the instance to get the user id -
mastodonFollowers()
: accepts the user's id and returns a list of follower data.
we won't be covering this functionality in this post. the full script is available as a github gist.
outputting arrays in a nice table format
macrame allows us to output array data in formatted ascii tables. if you've ever selected stuff from mysql on the command line, it looks like that.
let's look at the tableFollowers
function that accepts an array of follower data from mastodon and returns it as a table:
function tableFollowers(array $followers, Macrame $macrame): string
{
// extract the 'acct' and 'display_name' fields from each element for use as table rows
$data = array_map(fn ($f) => [$f->acct, $f->display_name], $followers);
$headers = ['Account', 'Display Name'];
// build the ascii table and return
return $macrame->table($headers, $data)->get();
}
we can gloss over the array_map
call here; all it does is extract the account and display names of the followers from the array of data from mastodon. the important line here is:
return $macrame->table($headers, $data)->get();
here, we used macrame's table()
method to create a macrame table object. the table()
method takes two arguments: an array of column headers, and an array of arrays that represent the rows of our table.
once we've made our table object, we use it to create and return our table by calling the get()
method. if we instead wanted to just write our table to the screen, we could call write()
instead.
if we were creating this table using hardcoded data, it might look something like this:
$headers = ['Account', 'Display Name'];
$data = [
['ghorwood', 'grant horwood'],
['thephp', 'The PHP Foundation'],
];
$table = $macrame->table($headers, $data)->get();
the value in our $table
variable would be a string that looks like this:
+----------+--------------------+
| Account | Display Name |
+----------+--------------------+
| ghorwood | grant horwood |
| thephp | The PHP Foundation |
+----------+--------------------+
a nice, clean table.
macrame also allows us to set the alignment of columns and change the border style if we so wish. for instance, if we want to right-align our Account
column and apply a solid border, we could write:
$table = $macrame->table($headers, $data)
->right(0)
->solid()
->get();
here, we added a call to the right
method to right-align the first column (column zero). we also applied the solid
method to set the border style to a solid line. the output is:
┌──────────┬────────────────────┐
│ Account │ Display Name │
├──────────┼────────────────────┤
│ ghorwood │ grant horwood │
│ thephp │ The PHP Foundation │
└──────────┴────────────────────┘
macrame's table feature is based on the the tabletown library and is designed to handle multibyte content, output rows with line breaks as multi-row cells, and deal with tabs as proper tab stops.
a full overview of the table feature is available on the table documentation page.
running code in the background and showing an animated spinner.
it's nice to show our users an animated spinner while we do time-intensive tasks in the background so they know that the script hasn't hung and is actually doing things. doing this in php, however, is not very straightforward since it requires us to run two different functions at the same time -- one to do the work, the other to show the spinner -- and php is single threaded. macrame's spinner
feature allows us to do this by forking a separate process.
let's look at how we could show an animated spinner to our users while we fetch and build our table of followers. this code lives in the main body of our script.
$getFollowersTable = function (string $username, string $instance, Macrame $macrame) {
// call mastodon to get the user id for the username
$userId = mastodonUserId($username, $instance, $macrame);
// call mastodon to get the followers for the user id
$followers = mastodonFollowers($userId, $instance, $macrame);
// format the array of followers into a table
$followersTable = tableFollowers($followers, $macrame);
return $followersTable;
};
/**
* Run the $getFollowersTable() function in the background and display an animated
* spinner to the user while it is running.
*/
$followersTable = $macrame->spinner('cycle 2')
->prompt('fetching ')
->speed('fast')
->run($getFollowersTable, [$username, $instance, $macrame]);
the spinner features allows us to show an animated spinner to our user while a function is run in the background, so the first step is to construct that function.
here, we have created an anonymous function and assigned it to the variable $getFollowersTable
. in the body of the function, we're calling mastodon to get a list of followers and formatting the response as a table. the function accepts three arguments and returns a string.
the run
method on spinner
is where we actually run the function. run
accepts two arguments. the first is the anonymous function, and the second is an array containing the values we want to pass as arguments to that function.
calling run
will automatically execute our anonymous function in the background while showing the animated spinner to the user. when the anonymous function terminates, run
will return its return value. in our example, the string containing the table of followers will be set in $followersTable
.
the spinner
feature allows a fair amount of customization. to change the type of animation we display, we can pass the name of the animation to spinner
as an argument. there are about forty animations to choose from. if no argument is passed to spinner
, the default animation is used.
we can also add a string that will preceed the animation by adding an optional call to prompt
. in the example above, we are outputting the word 'fetching' in front of the spinner.
lastly, we can adjust the speed of the animation with the optional speed
method. we can pass one of four strings to speed
: 'slow', 'medium', 'fast', or 'very fast'.
there is a full overview of the spinner feature in the manual.
writing to files safely
when we write php for the web, we don't normally need to put a lot of thought into file access. we control the server, after all, so we're confident about things like the directory structure, permissions, how much disk space we have and so on. that's not the case for command line scripts that are going to be run on some random user's computer.
macrame's file
feature has error checking for common read and write problems built in. let's look at how we would write a feature for our script that allows users to output the list of mastodon followers to a file:
// get optional outfile file path argument
$outfile = $macrame->args('outfile')->first();
if ($outfile) {
$macrame->file($outfile)->write($followersTable);
}
here, we're accepting a path to an output file as a command line arg. we covered how to do this in part one of this series. we're then taking that file path and writing our table of followers to it.
the file
method here takes as an argument the path to the file we want to write to. the write
method's argument is the contents we wish to write. if we wanted to append to the file, we would use the append
method instead.
when we call write
or append
, macrame checks first to see if we can write to the file. the checks performed are:
- if the path is valid
- permissions on the file if the file exists
- permissions on the directory if the file is to be created
- if there is sufficient disk space on the target partition
if any of these checks fail, error messages will be printed to the screen and the method will return false
. if everything goes well, true
is returned and our content is written to file.
we can also perform the checks manually if we want to override the default behaviour:
// bool. Test if file can be written to
if(!$macrame->file('/path/to/file')->writable()) {
// error. cannot write due to permissions
}
if(!$macrame->file('/path/to/file')->enoughSpace('content')) {
// error. cannot write due to lack of disk space
}
here, we're checking the permissions of our target file (or directory if the file does not exist yet), and confirming that we have sufficient space to write '$content'.
reading files operates similarly to writing them:
$contents = $macrame->file('/path/to/file')->read();
if there are any errors on this attempted read, they will be displayed to the user and the method will return null
.
macrame file paths are automatically expand the ~
character to the user's home directory.
temporary files
if we want to create temporary files that are automatically deleted when the script terminates, we can apply the deleteOnExit
method:
$macrame->file('/path/to/file')->deleteOnExit()->write('contents');
doing this flags the file to be removed when our script calls:
$macrame->exit();
the full list of file features is covered in the manual
paging long output
our list of followers might be too long to fit all in one screen. we could just let the output zip by and let whoever's running our script use the mouse wheel to get to the top, but that's rude.
macrame allows us to page long output with the page
method. let's apply paging to our table of followers:
$macrame->text($followersTable)->page();
here, we create a text object of our table string using text
and then, instead of calling write
to print it to the screen, we call page
to print it to the screen one page at a time.
our user's will be able to advance the output one page using the <SPACE>
bar. hitting <RETURN>
will move it forward one line.
outputting notices
keeping users informed of how the script is running is a good thing to do. if a tasks is completed successfully, it's nice to output an 'OK' message. if there's an error, we should show them some 'ERROR' text, preferably with some red in there.
macrame provides a number of convenience methods for text
to allow us to write notices to the screen.
$macrame->text('things went well')->ok();
$macrame->text('things did not go well')->error();
the first line here prints our message to the screen with a nice, green OK
. the second one has a red ERROR
.
these convenience methods follow (loosely) the levels outlined in RFC 5424. there's a full list of them in the documentation
putting it all together
now that we have a grip on building command line interfaces, we can assemble the mastodon follower app.
#!/usr/bin/env php
<?php
/**
* Example Macrame script that fetches mastodon followers.
*
* https://macrame.fruitbat.studio/Installation.html
* https://github.com/gbhorwood/Macrame
*/
require __DIR__ . '/vendor/autoload.php';
use Gbhorwood\Macrame\Macrame;
/**
* Instantiate a Macrame object.
* The argument is the name of the script as seen by ps(1)
*/
$macrame = new Macrame("Example Macrame Script");
/**
* Enforce that the script only runs if executed on the command line
*/
if ($macrame->running()) {
// ENTRY POINT
/**
* Validate that the host system can run Macrame scripts. Exit on failure
*/
$macrame->preflight();
/**
* Handle the -v or --version arguments
*/
if ($macrame->args('v')->exists() || $macrame->args('version')->exists()) {
$macrame->text('1.0')->write();
$macrame->exit();
}
/**
* Handle the --instance= argument if present, or poll user for instance
* with dynamic menu if not.
*/
$instance = $macrame->args('instance')->first();
if (!$instance) {
$instance = menuInstance($macrame);
}
/**
* Handle the --username= argument if present, or poll user for username
* with text input if not
*/
$username = $macrame->args('username')->first();
if (!$username) {
$username = inputUsername($instance, $macrame);
}
/**
* Read the value of the --outfile= argument if any
*/
$outfile = $macrame->args('outfile')->first();
/**
* A function to fetch an ascii table of followers from mastodon for the user
* defined by $username and $instance.
* @param string $username
* @param string $instance
* @param Macrame $macrame
* @return string
*/
$getFollowersTable = function (string $username, string $instance, Macrame $macrame) {
// call mastodon to get the user id for the username
$userId = mastodonUserId($username, $instance, $macrame);
// call mastodon to get the followers for the user id
$followers = mastodonFollowers($userId, $instance, $macrame);
// format the array of followers into a table
$followersTable = tableFollowers($followers, $macrame);
return $followersTable;
};
/**
* Run the $getFollowersTable() function in the background and display an animated
* spinner to the user while it is running.
*/
$followersTable = $macrame->spinner('cycle 2')
->prompt('fetching ')
->speed('fast')
->run($getFollowersTable, [$username, $instance, $macrame]);
/**
* If the --outfile= argument was passed, write the followers table to the file
* otherwise write the followers table to STDOUT, paged to screen height
*/
if ($outfile) {
$macrame->file($outfile)->write($followersTable);
} else {
$macrame->text($followersTable)->page();
}
// exit cleanly
$macrame->exit();
}
/**
* Display a dynamic menu of instances to the user, return
* the selected text.
*
* @param Macrame $macrame
* @return string
*/
function menuInstance(Macrame $macrame): string
{
$header = "Select your instance";
// list of options in the menu
$instances = [
'phpc.social',
'mastodon.social',
'mstdn.ca',
];
// display the menu, return the selected text
return $macrame->menu()
->erase() // erase the menu after selection
->interactive($instances, $header);
}
/**
* Display a text input to the user, return the input text.
*
* @param string $instance
* @param Macrame $macrame
* @return string
*/
function inputUsername(string $instance, Macrame $macrame): string
{
// the prompt for the text input, with bold styling using tags
$prompt = $macrame->text("<!BOLD!>Username<!CLOSE!> (for $instance): ")
->get();
/* alternate method to apply bold styling:
$prompt = $macrame->text('Username ')
->style('bold')
->get()."(for $instance): ";
*/
// read one line of user input, return the text
return $macrame->input()
->readline($prompt);
}
/**
* Convert the followers data returned by the mastodon instance into an ascii
* table and return.
*
* @param array $followers The array returned by mastodonFollowers()
* @param Macrame $macrame
* @return string
*/
function tableFollowers(array $followers, Macrame $macrame): string
{
// extract the 'acct' and 'display_name' fields from each element for use as table rows
$data = array_map(fn ($f) => [$f->acct, $f->display_name], $followers);
$headers = ['Account', 'Display Name'];
// build the ascii table and return
return $macrame->table($headers, $data)->get();
}
/**
* Fetch the id of the user $username from the mastodon instance $instance
*
* @param string $username
* @param string $instance
* @param Macrame $macrame
* @return int
*/
function mastodonUserId(string $username, string $instance, Macrame $macrame): int
{
$url = "https://$instance/api/v1/accounts/lookup?acct=$username";
// make the api call and handle errors
try {
return get($url, $macrame)->id;
} catch (\Exception $e) {
// display error text and exit the script
$macrame->text("User '$username' not found on '$instance'")->error();
$macrame->exit();
}
}
/**
* Fetch the array of the followers for the user $userId from the mastodon instance $instance
*
* @param string $userId
* @param string $instance
* @param Macrame $macrame
* @return int
*/
function mastodonFollowers(int $userId, string $instance, Macrame $macrame): array
{
$url = "https://$instance/api/v1/accounts/$userId/followers?limit=80";
try {
return get($url, $macrame);
} catch (\Exception $e) {
$macrame->text("Followers for '$username' not found on '$instance'")->error();
$macrame->exit();
}
}
/**
* Execute curl GET call to $url and return result
*
* @param string $url
* @param Macrame $macrame
* @return mixed
*/
function get(string $url, Macrame $macrame)
{
$headers = [
'Accept: application/json',
];
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
$header = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($header !== 201 && $header !== 200) {
curl_close($ch);
$macrame->text("Call to $url returned $header")->warning();
throw new \Exception();
}
curl_close($ch);
return json_decode($result);
}
the complete source for this project is also available as a gist.
🔎 this post originally appeared in the grant horwood technical blog
Top comments (0)