Perl Part I · Module 15 · Command-Line Perl and Script Execution
Validating Arguments and Printing Usage
Chapter 15.2 got the options out of the command line. That is not the same as knowing they are usable: a threshold of -5 parses perfectly, an input file that does not exist parses perfectly, and an output directory you cannot write to parses perfectly. Each of them fails later — sometimes much later, after the script has read a large report and done the expensive part. Checking first costs a dozen lines and changes how the tool feels to use, because every failure arrives in the first second with a message naming what to fix. This chapter measures that difference, settles which stream a usage message belongs on, and shows why reporting every problem at once beats stopping at the first.
Foundation13 min readPerlPart ICommand Line
Part I · Module 15 · Chapter 15.3 · Validating Arguments and Printing Usage
1. Fail Before You Do The Work
The argument for checking first is not tidiness. It is what the script leaves behind when it fails late.
use strict;
use warnings;
use File::Temp qw(tempdir);
use File::Spec;
my $dir = tempdir(CLEANUP => 1);
# two versions of the same tool: one checks first, one does not
my %version = (
"unchecked.pl" => <<'PROG',
my ($in, $out) = @ARGV;
open my $w, ">", $out or die "cannot write $out: $!\n";
my $rows = 0;
for my $n (1 .. 500) { # the "expensive" part
print $w "row $n\n";
$rows++;
}
unless (-f $in) { # checked far too late
print STDERR "no such input file: $in (after writing $rows rows)\n";
exit 2;
}
print "unchecked: finished\n";
PROG
"checked.pl" => <<'PROG',
my ($in, $out) = @ARGV;
unless (-f $in) { # checked FIRST
print STDERR "no such input file: $in\n";
exit 2; # a deliberate status
}
open my $w, ">", $out or die "cannot write $out: $!\n";
my $rows = 0;
for my $n (1 .. 500) { print $w "row $n\n"; $rows++ }
print "checked: wrote $rows rows\n";
PROG
);
for my $name (sort keys %version) {
open my $w, ">", File::Spec->catfile($dir, $name) or die "cannot write: $!\n";
print $w $version{$name};
close $w;
}
for my $name (qw(checked.pl unchecked.pl)) {
my $out_file = "out-$name.txt";
unlink File::Spec->catfile($dir, $out_file);
my $output = `cd '$dir' && perl $name missing-input.rpt $out_file 2>&1`;
my $rc = $? >> 8;
$output =~ s/\s+\z//;
my $left = File::Spec->catfile($dir, $out_file);
my $size = -e $left ? -s $left : -1;
printf "%-13s exit %-3d partial file left: %-9s message: %s\n",
$name, $rc,
($size < 0 ? "none" : "$size bytes"),
$output;
}checked.pl exit 2 partial file left: none message: no such input file: missing-input.rpt
unchecked.pl exit 2 partial file left: 3892 bytes message: no such input file: missing-input.rpt (after writing 500 rows)Both scripts failed, and both told you the input was missing. The difference is the third column.
The checked version left nothing behind. It refused before opening the output file at all.
The unchecked version wrote a complete-looking output file and only then discovered the input was missing. That file is the real problem: it exists, it has plausible contents, and the next step in a flow — a Makefile rule, a downstream script, a person — has no way to know it is garbage. Chapter 14.1 called this the third and most dangerous kind of failure, and here it is produced purely by checking in the wrong order.
The rule is simple: validate everything you can before you create, write or delete anything.
2. The Usage Message, And Which Stream It Goes On
Every tool needs a usage message. Where it goes, and what the exit status is, depends on why it is being printed — and this trips people up.
use strict;
use warnings;
use File::Temp qw(tempdir);
use File::Spec;
my $dir = tempdir(CLEANUP => 1);
my $tool = File::Spec->catfile($dir, "tool.pl");
open my $w, ">", $tool or die "cannot write: $!\n";
print $w <<'PROG';
use strict;
use warnings;
use Getopt::Long;
Getopt::Long::Configure("no_auto_abbrev", "no_ignore_case");
sub usage {
return <<"USAGE";
usage: $0 --input FILE [options]
--input FILE timing report to read (required)
--threshold NUM report slack below NUM (default 0.0)
--outdir DIR where to write the summary (default .)
--verbose say what is being done
--help print this message
USAGE
}
my %opt = (threshold => 0.0, outdir => ".", verbose => 0);
GetOptions(\%opt, "input=s", "threshold=f", "outdir=s", "verbose!", "help")
or do { print STDERR usage(); exit 2 };
# --help was ASKED FOR: it is the output, so stdout and a clean exit
if ($opt{help}) { print STDOUT usage(); exit 0 }
# a missing requirement is an ERROR: stderr, and a non-zero exit
unless (defined $opt{input}) {
print STDERR "error: --input is required\n\n", usage();
exit 2;
}
print "would process $opt{input}\n";
PROG
close $w;
for my $args ("--help", "", "--bogus", "--input setup.rpt") {
my $o = `cd '$dir' && perl tool.pl $args 2>/dev/null`;
my $e = `cd '$dir' && perl tool.pl $args 2>&1 >/dev/null`;
my $rc = $? >> 8;
printf "%-18s exit %-3d stdout %-2d line(s) stderr %-2d line(s)\n",
($args eq "" ? "(no arguments)" : $args), $rc,
scalar(split /\n/, $o), scalar(split /\n/, $e);
}--help exit 0 stdout 7 line(s) stderr 0 line(s)
(no arguments) exit 2 stdout 0 line(s) stderr 9 line(s)
--bogus exit 2 stdout 0 line(s) stderr 8 line(s)
--input setup.rpt exit 0 stdout 1 line(s) stderr 0 line(s)Three different outcomes, and the pattern behind them is worth stating plainly:
--help is a request, so the usage message is the output. It goes to standard output and the script exits 0. This is what makes tool --help | less and tool --help > notes.txt work. A help message on standard error cannot be piped, which is why it is worth getting right.
No arguments is an error. The usage goes to standard error with a non-zero exit, so it does not contaminate a pipeline that was expecting data.
A bad option is also an error, and it gets both the parser's warning and the usage, on standard error.
For the exit status, the common convention is 2 for a usage error and 1 for a run that started properly and then failed. Note that both scripts above choose their status with an explicit exit 2 rather than letting die pick one — Chapter 15.4 measures why that matters, because die does not reliably exit 255.
3. What Is Worth Checking
Six checks cover almost everything a command-line tool needs. Here they are, each measured passing and failing.
use strict;
use warnings;
use File::Temp qw(tempdir);
use File::Spec;
my $dir = tempdir(CLEANUP => 1);
# fixtures: a good file, an empty one, and a directory
my $good = File::Spec->catfile($dir, "good.rpt");
open my $g, ">", $good or die "cannot write: $!\n";
print $g "U1/Q 0.44\n";
close $g;
my $empty = File::Spec->catfile($dir, "empty.rpt");
open my $e, ">", $empty or die "cannot write: $!\n"; close $e;
my $subdir = File::Spec->catdir($dir, "sub");
mkdir $subdir or die "cannot mkdir: $!\n";
sub check {
my (%a) = @_;
my @bad;
push @bad, "--input is required" unless defined $a{input};
push @bad, "no such file: $a{input}" if defined $a{input} && !-e $a{input};
push @bad, "not a plain file: $a{input}" if defined $a{input} && -e $a{input} && !-f $a{input};
push @bad, "cannot read: $a{input}" if defined $a{input} && -f $a{input} && !-r $a{input};
push @bad, "input is empty: $a{input}" if defined $a{input} && -f $a{input} && -z $a{input};
push @bad, "threshold must be a number, got '$a{threshold}'"
if defined $a{threshold} && $a{threshold} !~ /\A-?\d+(?:\.\d+)?\z/;
push @bad, "threshold out of range: $a{threshold}"
if defined $a{threshold} && $a{threshold} =~ /\A-?\d+(?:\.\d+)?\z/
&& ($a{threshold} < -10 || $a{threshold} > 10);
push @bad, "no such directory: $a{outdir}" if defined $a{outdir} && !-d $a{outdir};
push @bad, "directory not writable: $a{outdir}" if defined $a{outdir} && -d $a{outdir} && !-w $a{outdir};
return @bad;
}
my @cases = (
[ "all good", { input => $good, threshold => "0.1", outdir => $subdir } ],
[ "missing --input", { threshold => "0.1", outdir => $subdir } ],
[ "file does not exist", { input => "$dir/nope.rpt", threshold => "0.1", outdir => $subdir } ],
[ "input is a directory", { input => $subdir, threshold => "0.1", outdir => $subdir } ],
[ "input is empty", { input => $empty, threshold => "0.1", outdir => $subdir } ],
[ "threshold not a number", { input => $good, threshold => "0.1ns", outdir => $subdir } ],
[ "threshold out of range", { input => $good, threshold => "42", outdir => $subdir } ],
[ "outdir missing", { input => $good, threshold => "0.1", outdir => "$dir/nodir" } ],
);
for my $c (@cases) {
my ($label, $args) = @$c;
my @bad = check(%$args);
(my $first = ($bad[0] // "(accepted)")) =~ s/\Q$dir\E/TMPDIR/g;
printf "%-22s %d problem(s) %s\n", $label, scalar @bad, $first;
}all good 0 problem(s) (accepted)
missing --input 1 problem(s) --input is required
file does not exist 1 problem(s) no such file: TMPDIR/nope.rpt
input is a directory 1 problem(s) not a plain file: TMPDIR/sub
input is empty 1 problem(s) input is empty: TMPDIR/empty.rpt
threshold not a number 1 problem(s) threshold must be a number, got '0.1ns'
threshold out of range 1 problem(s) threshold out of range: 42
outdir missing 1 problem(s) no such directory: TMPDIR/nodirEach row is one -X file test from Chapter 13.6 doing a specific job:
-e— does it exist at all?-f— is it a plain file? A directory passed where a file was expected is a real mistake, and theinput is a directoryrow shows it caught as exactly one problem. That is the point of guarding the later checks with-f: without the guard, a directory would also trip the readable and empty tests and produce three confusing messages about one mistake.-r— can this user read it? Permission problems are common on shared project areas.-z— is it empty? An empty report is almost always a failed upstream step rather than a run with no violations, and treating it as "zero violations" is how a broken flow reports success.- A pattern, not just a numeric comparison —
0.1nsis caught here, where$threshold < 10would have silently accepted it as0.1. That is Chapter 14.7's measured warning, prevented. -dand-w— the output directory must exist and be writable, checked before the work starts, per section 1.
4. Report Every Problem, Not The First
Notice that check above returns a list rather than dying. That is deliberate, and it is worth measuring why.
use strict;
use warnings;
my %given = (input => "", threshold => "high", outdir => "/no/such/dir", jobs => "-4");
# version A: stop at the first problem
sub die_on_first {
my (%a) = @_;
die "--input is required\n" if $a{input} eq "";
die "threshold must be a number\n" if $a{threshold} !~ /\A-?[\d.]+\z/;
die "no such directory: $a{outdir}\n" unless -d $a{outdir};
die "jobs must be positive\n" if $a{jobs} <= 0;
return;
}
# version B: collect them all
sub collect_all {
my (%a) = @_;
my @bad;
push @bad, "--input is required" if $a{input} eq "";
push @bad, "threshold must be a number" if $a{threshold} !~ /\A-?[\d.]+\z/;
push @bad, "no such directory: $a{outdir}" unless -d $a{outdir};
{ no warnings 'numeric'; push @bad, "jobs must be positive" if $a{jobs} <= 0 }
return @bad;
}
# how many RUNS does each approach need to surface every problem?
my %fixed = %given;
my $runs = 0;
while (1) {
$runs++;
my $err = "";
{ local $@; eval { die_on_first(%fixed); 1 } or $err = $@ }
last unless $err;
$err =~ s/\s+\z//;
print " run $runs reported: $err\n";
# the user fixes exactly that one thing and runs again
$fixed{input} = "r.rpt" if $err =~ /input/;
$fixed{threshold} = "0.1" if $err =~ /threshold/;
$fixed{outdir} = "." if $err =~ /directory/;
$fixed{jobs} = 4 if $err =~ /jobs/;
last if $runs > 10;
}
printf "die-on-first: %d runs to find them all\n\n", $runs - 1;
my @all = collect_all(%given);
printf "collect-all : 1 run, %d problem(s):\n", scalar @all;
print " - $_\n" for @all; run 1 reported: --input is required
run 2 reported: threshold must be a number
run 3 reported: no such directory: /no/such/dir
run 4 reported: jobs must be positive
die-on-first: 4 runs to find them all
collect-all : 1 run, 4 problem(s):
- --input is required
- threshold must be a number
- no such directory: /no/such/dir
- jobs must be positiveFour problems. The die-on-first version needed four runs to show them, because each run reveals exactly one and the user fixes it and tries again. The collecting version showed all four in one.
That difference is small with four problems and a script that starts instantly. It stops being small when the tool takes two minutes to reach its first check, or when the person running it is on a different continent from the person who wrote it.
The shape to copy:
my @bad = check(%opt);
if (@bad) {
print STDERR "error: $_\n" for @bad;
print STDERR "\n", usage();
exit 2;
}Three details in those five lines:
The checks themselves do not exit. check returns data. That makes it testable — you can call it with a hash of bad values and assert the list that comes back, which you cannot do with a function that dies.
Each problem is one line, prefixed, on standard error. Chapter 14.2's reasoning: one pass, every problem named.
The usage message follows, so a user who has forgotten an option sees both what is wrong and what was available, without running the tool a second time with --help.
5. What To Remember
- Validate before you create, write or delete anything. Measured: the unchecked version left a complete-looking, wrong output file behind; the checked version left nothing.
- A plausible partial file is the dangerous outcome — nothing downstream can tell it is garbage.
--helpis a request: usage on standard output, exit 0, so it can be piped.- A missing or bad option is an error: usage on standard error, non-zero exit, so it never contaminates a pipeline.
- Write the usage text once in a subroutine, and use
$0so a renamed copy still prints a correct line. - Six checks cover most tools —
-eexists,-fis a plain file,-rreadable,-zempty, a pattern for numbers,-dand-wfor the output directory. - Check the number with a pattern, not a comparison —
0.1nspasses< 10and is caught by\A-?\d+(?:\.\d+)?\z. - An empty input is usually a failed upstream step, not a clean run with nothing to report.
- Check the output directory before the work, and for a long job write and delete a byte rather than trusting
-w. - Collect problems into a list; do not die on the first. Measured: 4 problems took 4 runs die-on-first, and 1 run collecting.
- Make the check function return data, not exit — that is what makes it testable.
- Print every problem, then the usage, then exit
2.
Chapter 15.4 takes the exit status seriously. Printing FAIL tells a person something; only the status tells a Makefile or a CI job anything — and there are two measured ways a failing script reports success.
Where this fits
Part of the Perl curriculum.
