Simple CLIs using Do v1.70

Command-line Interfaces using Modern Perl

If you Perl or you're Perl-curious, or you build command-line interfaces, you should read this, but before we dive in building the command-line application, lets first talk about the command line.

Command-line programs have been with us since the early days of the computer and are programs based upon on commands (single or multiple). A command-line program is a program that operates from the command-line or shell.

A command-line interface is a user interface that is navigated by typing commands in a terminal, shell or console, as opposed to using a GUI (graphical user interface). The console is a display mode for which the entire monitor screen shows only text, no images or GUI objects.

According to Wikipedia:

The CLI was the primary means of interaction with most computer systems on computer terminals in the mid-1960s, and continued to be used throughout the 1970s and 1980s on OpenVMS, Unix systems and personal computer systems including MS-DOS, CP/M and Apple DOS. The interface is usually implemented with a command line shell, which is a program that accepts commands as text input and converts commands into appropriate operating system functions.

Why Perl?

Perl is usually regarded as a glue code language, e.g. "the duct tape of the Internet", because of its ubiquitousness, flexibility, and well-known powerful text processing capabilities (e.g. its native regular expression engine). Most Perl code is written as scripts and command-line utilities. Contrary to the opinion of some, Perl programming can be extremely well-structured and beautiful, leveraging many advanced concepts found in other languages, and some which aren't.

Building command-line interfaces and tools with Perl can be extremely simple, yet powerful because Perl makes it possible to automate almost anything you want, and modern Perl allows you to leverage modern software development concepts and techniques to create powerful, sophisticated, and elegant software programs.

Use Do

The Do project's slogan is, "If you're doing something modern with Perl, start here!", as it provides a framework for modern Perl development, embracing Perl's multi-paradigm programming nature, flexibility and vast ecosystem that many of engineers already know and love. The power of this framework comes from the extendable (yet fully optional) type library which is integrated into the object system and type-constrainable subroutine signatures (supporting functions, methods and method modifiers). You can install Do by running:

$ cpanm -qn Do@1.70

If you don't have cpanm, get it! It takes less than a minute, otherwise:

$ curl -L https://cpanmin.us | perl - -qn Do@1.70

The Goals

Perl, its community and ecosystem, typically embrace a philosophy of layering and modularization. It's as much of a strength as it is a weakness. The prerequisites for a basic command-line interface is, obviously, the automatic execution of the application code, command-line argument handling, command-line flags, and options handling, and some basic support for printing documentation (e.g. help and usage text). The ultimate goal for me was to provide a simple command-line interface abstraction, not a full-featured framework, with just enough functionality, some sugar, and an evolvable architecture. When I started thinking about what the simplest way to create an evolvable command-line application might look like, I naturally thought about implementing it as a base class with overridable routines that perform the most common tasks.

Hello World

#!/usr/bin/env perl

package Download;

use Do 'Cli';

# entrypoint
method main() {
  say "You're Fetching";
}

run Download;

Running ./download should produce:

$ You're Fetching

This is a basic hello world example. The use statement modifies the Download package, making it a class derived from the Data::Object::Cli abstract base class. The run function uses Perl's indirect object notation to invoke itself. The base class is designed as a Modulino, which is Perl-speak for a package that acts as both a script as well as a module, which means that this class can be instantiated and passed around like an object in a more sophisticated system, while also being able to be run as a command-line program. This application isn't very interest as-is, after all, it's not even taking in any input from the user. The abstraction also includes an object for interacting with the command-line arguments, options, and flags, available as the class attributes, args and opts but also passed to the main method as key/value pairs for easy unpacking. For example:

#!/usr/bin/env perl

package Download;

use Do 'Cli';

method main(:$args) {
  my $file = $args->get(0) || 'Kinda';

  say "You're Fetching, $file";
}

run Download;

Running ./download Something should produce:

$ You're Fetching, Something

More With Less

Again, for convenience and to avoid tedious unpacking, the args, data, opts, and vars objects are made available as method arguments and object attributes. The args object represents positional command-line arguments and is an instance of the Data::Object::Args class. The data object represents inline documentation and is an instance of the Data::Object::Data class. The opts object represents command-line options and flags and is an instance of the Data::Object::Opts class. The vars object represents environment variables and is an instance of the Data::Object::Vars class. The option and flags parsing use Getopt::Long internally and as such requires valid options specifications. To provide option specs, simply create a spec method that returns an arrayref of option specs, or use POD annotation; For example:

#!/usr/bin/env perl

package Download;

use Do 'Cli';

method main(:$args, :$opts) {
  my $file = $args->get(0) || 'Kinda';

  say "You're Fetching, $file\n";

  # dump processed options
  $opts->stashed->dump->say;
}

run Download;

__DATA__

=spec

tag=s, token=s, quiet|q

=cut

The options spec, specified in POD annotation, instructs the options parser to look for --tag= and --token= options. The args object can access positional arguments via alias if specified. To provide argument aliases, simply create a sign method that returns a hashref of name/index pairs, or use POD annotation; For example:

#!/usr/bin/env perl

package Download;

use Do 'Cli';

method main(:$args, :$opts) {
  my $file = $args->file || 'Kinda';

  say "You're Fetching, $file\n";

  # dump processed options
  $opts->stashed->dump->say;
}

run Download;

__DATA__

=sign

{file}

=spec

tag=s, token=s, quiet|q

=cut

You may have noticed that the example is now calling $args->file instead of $args->get(0). That how positional argument aliases work!

Something Useful

While continuing to build on the example, let's make something useful. I use a service called Pinboard to keep track of my bookmarks and to tag them in interesting ways. What's very cool is that Pinboard provides an API which makes downloading and automating actions on your bookmarks easy. Let's modify this command-line application to download bookmarks with a given tag and save them in a file as a YAML document. To do that, we'll need to pull in some dependencies from the CPAN. You can install the dependencies by running:

$ cpanm -qn WWW::Pinboard YAML::Tiny

That's it!

Command Validation

The goals of this command-line application aren't sophisticated. We essentially want to specify a file, tag, and API token, and download a dataset from Pinboard. Still, we should validate the command-line input before executing the application logic. The following is an example of how we might validate the user input.

#!/usr/bin/env perl

package Download;

use Do 'Cli';

use WWW::Pinboard;
use YAML::Tiny;

method main(:$args, :$opts) {
  $self->exit(1) if !$self->handle('check');

  my $file = $args->file || 'Kinda';

  say "You're Fetching, $file\n";

  # dump processed options
  $opts->stashed->dump->say;
}

method check(:$args, :$opts) {
  return 0 if !$args->file;
  return 0 if !$opts->tag;
  return 0 if !$opts->token;

  return 1;
}

run Download;

__DATA__

=sign

{file}

=spec

tag=s, token=s, quiet|q

=cut

This example dispatches (i.e. calls with key/value pairs) to the check method which returns either truthy or falsy based on the truthiness of the command-line args and options. If you run this application without the requisite args and options, the application will simply exit with an exit code of 1

Usage Documentation

Instead of simply exiting on a failure to validate, we should output the usage text like most well-behaved command-line applications. The following is an example of one way to do that:

#!/usr/bin/env perl

package Download;

use Do 'Cli';

use WWW::Pinboard;
use YAML::Tiny;

method main(:$args, :$opts) {
  $self->fail('usage') if !$self->handle('check');

  my $file = $args->file || 'Kinda';

  say "You're Fetching, $file\n";

  # dump processed options
  $opts->stashed->dump->say;
}

method check(:$args, :$opts) {
  return 0 if !$args->file;
  return 0 if !$opts->tag;
  return 0 if !$opts->token;

  return 1;
}

method usage() {
  my $help = $self->help;

  # help is parsed as an arrayref of string
  return $help->join->say;
}

run Download;

__DATA__

=sign

{file}

=spec

tag=s, token=s, quiet|q

=help

usage: ./download <file> --tag=<tag> --token=<token> [--quiet|-q]

=cut

What we've done here is used the fail method to dispatch to the usage method before exiting with an exit code of 1. The usage method calls the superclass' help method which by default parses the POD annotation and returns an arrayref of strings. The usage text is then output to STDOUT.

Logic, Flow, Forward

Now that we've implemented a check that if fails will display the application's usage text, we can implement the actual program logic that will run once the correct input has been submitted. The following is an example of that:

#!/usr/bin/env perl

package Download;

use Do 'Cli';

use WWW::Pinboard;
use YAML::Tiny;

method main(:$args, :$opts) {
  $self->fail('usage') if !$self->handle('check');

  my $bookmarks = $self->handle('fetch');
  $self->handle('persist', bookmarks => $bookmarks);

  say "Bookmarks saved";
}

method check(:$args, :$opts) {
  return 0 if !$args->file;
  return 0 if !$opts->tag;
  return 0 if !$opts->token;

  return 1;
}

method usage() {
  my $help = $self->help;

  return $help->join->say;
}

method fetch(:$args, :$opts) {
  my $pinboard = WWW::Pinboard->new(token => $opts->token);
  my $bookmarks = $pinboard->all(tag => $opts->tag);

  return $bookmarks;
}

method persist(:$args, :$bookmarks) {
  my $yaml = YAML::Tiny->new({bookmarks => $bookmarks});

  return $yaml->write($args->file);
}

run Download;

__DATA__

=sign

{file}

=spec

tag=s, token=s, quiet|q

=help

usage: ./download <file> --tag=<tag> --token=<token> [--quiet|-q]

=cut

Conclusion

The Do project has good support for creating and evolving creating beautiful command-line interfaces in a composable way with as little code as necessary. The Data::Object::Cli abstraction aims to make the process of writing command-line tools quickly, simple and fun, while also preventing any frustration caused by the inability to implement the intended CLI API.

Thanks

The software in this is post is made possible by:

Do

WWW::Pinboard

YAML::Tiny

Help

If you appreciate this post and/or the code used, please star the project on CPAN and GitHub. I could also use your help if you're interested, i.e. ideas, feedback, bug reporting, bug fixes, pull requests, etc.

Links

Here are some links if you're interested in learning more!

GitHub

Projects

Milestones

Contributing

Issues

End

Leave a comment

About Al Newkirk

user-pic I blog about Perl.