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:
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!
Leave a comment