pericmd 010: Basic structure of a CLI application (Perinci::CmdLine)

As mentioned in the previous post, I’ll be showing the typical CLI application using Perinci::CmdLine. So after almost 10 posts in the series, this is the first post that contains the actual Perinci::CmdLine-using code.

But first 🙂 I want to emphasize that the framework is developed with DRY and laziness as the main principles. Aside from avoiding repetitions, like discussed in the previous posts, I also tend to minimize (or downright avoid) doing unnecessary plumbing, or having to write stuffs or rewrite stuffs if that is also unnecessary. The examples for this will be given along the way. In short, Perinci::CmdLine and the related specifications and libraries are the result of my particular way of being lazy.

So here’s a full example of a Perinci::CmdLine-based CLI application, taken from the App-CreateSparseFile repository:

package App::CreateSparseFile;

# DATE
# VERSION

use 5.010001;
use strict;
use warnings;

use File::MoreUtil qw(file_exists);
use IO::Prompt::I18N qw(confirm);

our %SPEC;

$SPEC{create_sparse_file} = {
    v => 1.1,
    summary => 'Create sparse file',
    description => <<'_',

Sparse file is a file with a predefined size (sometimes large) but does not yet
allocate all its (blank) data on disk. Sparse file is a feature of filesystem.

I usually create sparse file when I want to create a large disk image but do not
want to preallocate its data yet. Creating a sparse file should be virtually
instantaneous.

_
    args => {
        name => {
            schema => ['str*'],
            req => 1,
            pos => 0,
        },
        size => {
            summary => 'Size (e.g. 10K, 22.5M)',
            schema => ['str*'],
            cmdline_aliases => { s => {} },
            req => 1,
            pos => 1,
        },
        interactive => {
            summary => 'Whether or not the program should be interactive',
            schema => 'bool',
            default => 1,
            description => <<'_',

If set to false then will not prompt interactively and usually will proceed
(unless for dangerous stuffs, in which case will bail immediately.

_
        },
        override => {
            summary => 'Whether to override existing file',
            schema => 'bool',
            default => 0,
            description => <<'_',

If se to true then will override existing file without warning. The default is
to prompt, or bail (if not interactive).

_
        },
    },
    examples => [
        {
            argv => [qw/file.bin 30G/],
            summary => 'Create a sparse file called file.bin with size of 30GB',
            test => 0,
        },
    ],
};
sub create_sparse_file {
    my %args = @_;

    my $interactive = $args{interactive} // 1;

    # TODO: use Parse::Number::WithPrefix::EN
    my $size = $args{size} // 0;
    return [400, "Invalid size, please specify num or num[KMGT]"]
        unless $size =~ /\A(\d+(?:\.\d+)?)(?:([A-Za-z])[Bb]?)?\z/;
    my ($num, $suffix) = ($1, $2);
    if ($suffix) {
        if ($suffix =~ /[Kk]/) {
            $num *= 1024;
        } elsif ($suffix =~ /[Mm]/) {
            $num *= 1024**2;
        } elsif ($suffix =~ /[Gg]/) {
            $num *= 1024**3;
        } elsif ($suffix =~ /[Tt]/) {
            $num *= 1024**4;
        } else {
            return [400, "Unknown number suffix '$suffix'"];
        }
    }
    $num = int($num);

    my $fname = $args{name};

    if (file_exists $fname) {
        if ($interactive) {
            return [200, "Cancelled"]
                unless confirm "Confirm override existing file", {default=>0};
        } else {
            return [409, "File already exists"] unless $args{override};
        }
        unlink $fname or return [400, "Can't unlink $fname: $!"];
    } else {
        if ($interactive) {
            my $s = $suffix ? "$num ($size)" : $num;
            return [200, "Cancelled"]
                unless confirm "Confirm create '$fname' with size $s";
        }
    }

    open my($fh), ">", $fname or return [500, "Can't create $fname: $!"];
    if ($num > 0) {
        seek $fh, $num-1, 0;
        print $fh "\0";
    }
    [200, "Done"];
}

1;
# ABSTRACT:

=head1 SYNOPSIS

See L<create-sparse-file>.

=cut

The above is the source code for the backend module (located in lib/App/CreateSparseFile.pm). The script itself is located in bin/create-sparse-file. What is the source code for the script? Usually, I don’t even care anymore because it’s often generated. This is one example of the laziness: creating the actual script itself counts as a plumbing/chore and is rather boring and repetitive, so I often automate it away 🙂

Technically, a Dist::Zilla plugin will create a script which boils down to something like this:

#!perl

use Perinci::CmdLine::Any;
Perinci::CmdLine::Any->new(url=>'/App/CreateSparseFile/create_sparse_file')->run;

So the bulk of the code is in the module anyway.

There is a lot to explain in the above code, so this will take several posts. For this post, we’ll see two things you might notice in the script’s code.

First of all, why the ::Any in the module name? This is purely historical. There used to be just a single Perinci::CmdLine module, but as feature gets added and the module grows, so does startup overhead and that becomes a bit annoying. Not as much startup overhead as Moose-based application, but still it reaches 0.3-0.4s and this is becoming annoying for shell tab completion, because tab completion is handled dynamically using the script itself so the script must start fast. So at some point I’m developing a “lite” alternative called Perinci::CmdLine::Lite which aims to keep startup overhead below 0.05-0.10s. (The original Perinci::CmdLine later became Perinci::CmdLine::Classic.) Perinci::CmdLine::Lite started quite bare-bones, but as it gets developed further it begins to get more features, while still keeping in mind to stay low in startup overhead, because tab completion is IMO one of the most important things to have in a CLI application. There are now only a handful features missing from the ::Lite version. Perinci::CmdLine::Any is used to let user choose/switch/fallback between the two without changing the code. In the future, I envision the two versions to converge eventually.

OK so that’s a pretty boring history. Anyway, most of the time you should not care and just use ::Any.

Now the second, more interesting question would be: Why use URL??? Well, the framework was first developed for writing a CLI-based API client for an application which serves the API over HTTP (over Unix socket). Again, being lazy, I don’t want to have to do local stuffs and remote stuffs twice, if they will almost be the same. So I created a CLI application which can have the exact same code for local backend code (which is, Perl modules on the local filesystem), as well as remote backend code (which, frankly, can be anything over anything as long as it’s client-server style. It doesn’t have to be Perl.) So that’s another example of the lazy principle: if sometime in the future somehow some parts of the application needs to be rewritten in another language, I’d like to be able to keep doing the other things the same and minimize rewriting code to make the whole thing work, unless that’s necessary.

Turns out, to do local-remote transparency for a CLI application, I ended up having to “invent” a lot of other stuffs first 😦

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s