| File: | lib/SimpleFlow.pm |
| Coverage: | 90.4% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | # ABSTRACT: SimpleFlow - easy, simple workflow manager (and logger); for keeping track of and debugging large and complex shell command workflows | |||||
| 2 | 2 2 2 | 2 0 14 | use strict; | |||
| 3 | 2 2 2 | 3 2 33 | use warnings FATAL => 'all'; | |||
| 4 | require 5.010; | |||||
| 5 | 2 2 2 | 2 1 63 | use feature 'say'; | |||
| 6 | 2 2 2 | 53 9654 6 | use DDP {output => 'STDOUT', array_max => 10, show_memsize => 1}; | |||
| 7 | 2 2 2 | 288 5685 3 | use Devel::Confess 'color'; | |||
| 8 | 2 2 2 | 64 0 38 | use Cwd 'getcwd'; | |||
| 9 | package SimpleFlow; | |||||
| 10 | our $VERSION = 0.14; | |||||
| 11 | 2 2 2 | 2 1 4 | use Time::HiRes; | |||
| 12 | 2 2 2 | 340 5057 74 | use Term::ANSIColor; | |||
| 13 | # Windows portability: the legacy Windows console (cmd.exe) prints raw ANSI | |||||
| 14 | # escape sequences as garbage. Disable colouring there unless a terminal that | |||||
| 15 | # understands ANSI is in use (Windows Terminal, ConEmu, ANSICON). Unix and | |||||
| 16 | # modern Windows terminals are left untouched. | |||||
| 17 | BEGIN { | |||||
| 18 | $ENV{ANSI_COLORS_DISABLED} = 1 | |||||
| 19 | if $^O eq 'MSWin32' | |||||
| 20 | && !$ENV{WT_SESSION} # Windows Terminal | |||||
| 21 | && !$ENV{ConEmuANSI} # ConEmu | |||||
| 22 | 2 | 18 | && !$ENV{ANSICON}; # ANSICON | |||
| 23 | } | |||||
| 24 | 2 2 2 | 4 1 23 | use Scalar::Util 'openhandle'; | |||
| 25 | 2 2 2 | 2 2 5 | use DDP {output => 'STDOUT', array_max => 10, show_memsize => 1}; | |||
| 26 | 2 2 2 | 61 1 3 | use Devel::Confess 'color'; | |||
| 27 | 2 2 2 | 58 1 19 | use Cwd 'getcwd'; | |||
| 28 | 2 2 2 | 2 0 27 | use warnings FATAL => 'all'; | |||
| 29 | 2 2 2 | 240 2768 32 | use Capture::Tiny 'capture'; | |||
| 30 | 2 2 2 | 3 1 22 | use List::Util 'max'; | |||
| 31 | 2 2 2 | 2 2 1476 | use Exporter 'import'; | |||
| 32 | our @EXPORT = qw(say2 task); | |||||
| 33 | our @EXPORT_OK = @EXPORT; | |||||
| 34 | ||||||
| 35 | sub say2 { # say to both command line and log file | |||||
| 36 | 2 | 4 | my ($msg, $fh) = @_; | |||
| 37 | 2 | 6 | my $current_sub = (split(/::/,(caller(0))[3]))[-1]; # https://stackoverflow.com/questions/2559792/how-can-i-get-the-name-of-the-current-subroutine-in-perl | |||
| 38 | 2 | 30 | my @c = caller; | |||
| 39 | 2 | 21 | if (not openhandle($fh)) { | |||
| 40 | 1 | 5 | die "the filehandle given to $current_sub with \"$msg\" from $c[1] line $c[2] isn't actually a filehandle"; | |||
| 41 | } | |||||
| 42 | 1 | 1 | $msg = "\@ $c[1] line $c[2] $msg"; | |||
| 43 | 1 | 2 | say $msg; | |||
| 44 | 1 | 1 | say $fh $msg; | |||
| 45 | 1 | 1 | return $msg; | |||
| 46 | } | |||||
| 47 | ||||||
| 48 | sub task { | |||||
| 49 | 32 | 99 | my $current_sub = (split(/::/,(caller(0))[3]))[-1]; | |||
| 50 | # Accept either a single hash ref -- task({ cmd => ... }) -- or a flat | |||||
| 51 | # key/value list -- task(cmd => ...). A lone non-hashref scalar, or an | |||||
| 52 | # odd-length list, can't be read either way and is fatal. | |||||
| 53 | 32 | 548 | my $args; | |||
| 54 | 32 | 181 | if (@_ == 1 && ref $_[0] eq 'HASH') { | |||
| 55 | 29 | 23 | $args = $_[0]; | |||
| 56 | } elsif (@_ % 2 == 0) { | |||||
| 57 | 2 | 2 | $args = { @_ }; | |||
| 58 | } else { | |||||
| 59 | 1 | 4 | die "args to $current_sub must be a hash ref (e.g. $current_sub({ cmd => ... })) or a flat key/value list (e.g. $current_sub(cmd => ...)); got an odd-length list"; | |||
| 60 | } | |||||
| 61 | 31 | 37 | my @c = caller; | |||
| 62 | 31 | 316 | my @reqd_args = ( | |||
| 63 | 'cmd', # the shell command | |||||
| 64 | ); | |||||
| 65 | 31 31 | 45 51 | my @undef_args = grep { !defined $args->{$_}} @reqd_args; | |||
| 66 | 31 | 55 | if (scalar @undef_args > 0) { | |||
| 67 | 1 | 2 | p @undef_args; | |||
| 68 | 1 | 1146 | die 'the above args are necessary, but were not defined.'; | |||
| 69 | } | |||||
| 70 | 30 | 50 | my @defined_args = ( @reqd_args, | |||
| 71 | 'die', # die if not successful; 0 or 1 | |||||
| 72 | 'dry.run', # dry run or not | |||||
| 73 | 'input.files', # check for input files; SCALAR or ARRAY | |||||
| 74 | 'log.fh', | |||||
| 75 | 'note', # a note for the log | |||||
| 76 | 'overwrite', # | |||||
| 77 | 'output.files' # product files that need to be checked; can be scalar or array | |||||
| 78 | ); | |||||
| 79 | 30 64 64 512 30 | 25 44 47 326 36 | my @bad_args = grep { my $key = $_; not grep {$_ eq $key} @defined_args} keys %{ $args }; | |||
| 80 | 30 | 43 | if (scalar @bad_args > 0) { | |||
| 81 | 1 | 2 | p @bad_args, array_max => scalar @bad_args; | |||
| 82 | 1 | 1065 | say "the above arguments are not recognized by $current_sub"; | |||
| 83 | 1 | 1 | p @defined_args, array_max => scalar @defined_args; | |||
| 84 | 1 | 1703 | die "The above args are accepted by $current_sub"; | |||
| 85 | } | |||||
| 86 | 29 | 57 | if ( | |||
| 87 | (defined $args->{'log.fh'}) && | |||||
| 88 | (not openhandle($args->{'log.fh'})) | |||||
| 89 | ) { | |||||
| 90 | 1 | 1 | p $args; | |||
| 91 | 1 | 1236 | die "the filehandle given to $current_sub isn't actually a filehandle"; | |||
| 92 | } | |||||
| 93 | 28 | 43 | my (%input_file_size, @existing_files, @output_files, @empty_filenames); | |||
| 94 | 28 | 47 | if (defined $args->{'input.files'}) { | |||
| 95 | 6 | 5 | my $ref = ref $args->{'input.files'}; | |||
| 96 | 6 | 4 | my @missing_files; | |||
| 97 | 6 | 10 | if ($ref eq 'ARRAY') { | |||
| 98 | 1 2 1 | 1 7 1 | @missing_files = grep {not -f -r $_ } @{ $args->{'input.files'} }; | |||
| 99 | 1 2 1 | 1 4 1 | %input_file_size = map { $_ => -s $_ } @{ $args->{'input.files'} }; | |||
| 100 | 1 2 1 | 1 5 0 | @empty_filenames = grep {(defined $_) && (length $_ == 0)} @{ $args->{'input.files'} }; | |||
| 101 | } elsif ($ref eq '') { # scalar | |||||
| 102 | 4 4 | 6 12 | @missing_files = grep {not -f -r $_ } ($args->{'input.files'}); | |||
| 103 | 4 4 | 4 6 | %input_file_size = map { $_ => -s $_ } ($args->{'input.files'} ); | |||
| 104 | 4 4 | 4 13 | @empty_filenames = grep {(defined $_) && (length $_ == 0)} ($args->{'input.files'}); | |||
| 105 | } else { | |||||
| 106 | 1 | 2 | p $args; | |||
| 107 | 1 | 4371 | die 'ref type "' . $ref . '" is not allowed for "input.files"'; | |||
| 108 | } | |||||
| 109 | 5 | 6 | if (scalar @missing_files > 0) { | |||
| 110 | 3 | 10 | say STDERR 'this list of arguments:'; | |||
| 111 | 3 | 5 | p $args; | |||
| 112 | 3 | 3665 | say STDERR 'Cannot run because these files are either missing or unreadable in: ' . getcwd(); | |||
| 113 | 3 | 9 | p @missing_files; | |||
| 114 | 3 | 2994 | die 'the above files are missing or are not readable'; | |||
| 115 | } | |||||
| 116 | } | |||||
| 117 | 24 | 27 | if (scalar @empty_filenames > 0) { | |||
| 118 | 0 | 0 | p $args; | |||
| 119 | 0 | 0 | die '0-length filenames are not allowed (found in "input.files")'; | |||
| 120 | } | |||||
| 121 | 24 | 144 | my $msg = "\@ $c[1] line $c[2] The command is:\n" . colored(['blue on_bright_red'], $args->{cmd}); | |||
| 122 | 24 | 886 | say $msg; | |||
| 123 | 24 1 | 53 4 | say {$args->{'log.fh'}} "\@ $c[1] line $c[2] The command is:\n$args->{cmd}" if defined $args->{'log.fh'}; | |||
| 124 | 24 | 39 | if (defined $args->{'output.files'}) { # avoid "uninitialized value" warning | |||
| 125 | 8 | 13 | my $ref = ref $args->{'output.files'}; | |||
| 126 | 8 | 19 | if ($ref eq 'ARRAY') { | |||
| 127 | 1 1 | 0 1 | @output_files = @{ $args->{'output.files'} }; | |||
| 128 | } elsif ($ref eq '') { # a scalar | |||||
| 129 | 6 | 10 | @output_files = $args->{'output.files'}; | |||
| 130 | } else { | |||||
| 131 | 1 | 2 | p $args; | |||
| 132 | 1 | 1402 | die "$ref isn't allowed for \"output.files\""; | |||
| 133 | } | |||||
| 134 | } | |||||
| 135 | 23 7 | 26 22 | @empty_filenames = grep {(defined $_) && (length $_ == 0)} @output_files; # 0-length filenames aren't allowed | |||
| 136 | 23 | 27 | if (scalar @empty_filenames > 0) { | |||
| 137 | 1 | 2 | p $args; | |||
| 138 | 1 | 1237 | die '0-length filenames are not allowed (found in "output.files"'; | |||
| 139 | } | |||||
| 140 | 22 | 43 | if (scalar @output_files > 0) { | |||
| 141 | 6 6 | 6 24 | @existing_files = grep {-f $_} @output_files; | |||
| 142 | } | |||||
| 143 | my %r = ( | |||||
| 144 | cmd => $args->{cmd}, | |||||
| 145 | 22 | 147 | dir => getcwd(), | |||
| 146 | 'source.file' => $c[1], | |||||
| 147 | 'source.line' => $c[2], | |||||
| 148 | 'output.files' => [@output_files], | |||||
| 149 | ); | |||||
| 150 | 22 | 72 | $r{'die'} = $args->{'die'} // 1; # by default, true | |||
| 151 | 22 | 88 | $r{'dry.run'} = $args->{'dry.run'} // 0; # by default, false | |||
| 152 | 22 | 68 | $r{note} = $args->{note} // '';# by default, false | |||
| 153 | 22 | 56 | $r{overwrite} = $args->{overwrite} // 0; # by default, false | |||
| 154 | 22 | 19 | $r{'will.do'} = 'yes'; | |||
| 155 | 22 | 22 | $r{'will.do'} = 'no: dry run' if $args->{'dry.run'}; | |||
| 156 | 22 | 14 | my $string_max = 0; | |||
| 157 | 22 | 20 | if (defined $args->{'input.files'}) { | |||
| 158 | 2 | 1 | $r{'input.files'} = $args->{'input.files'}; | |||
| 159 | 2 | 1 | $r{'input.file.size'} = \%input_file_size; | |||
| 160 | } | |||||
| 161 | 22 6 | 21 26 | my %output_file_size = map {$_ => -s $_} @output_files; | |||
| 162 | 22 224 | 35 234 | foreach my $val (grep {ref $r{$_} eq ''} keys %r) { | |||
| 163 | 199 | 203 | $string_max = max($string_max, length $r{$val}); | |||
| 164 | } | |||||
| 165 | 22 | 82 | if ((!$args->{overwrite}) && (scalar @output_files > 0) && (scalar @existing_files == scalar @output_files)) { # this has been done before | |||
| 166 | 1 | 1 | $r{done} = 'before'; | |||
| 167 | 1 | 1 | $r{'will.do'} = 'no'; | |||
| 168 | 1 | 3 | say colored(['black on_green'], "\"$args->{cmd}\"\n") . ' has been done before'; | |||
| 169 | 1 | 23 | $r{'output.file.size'} = \%output_file_size; | |||
| 170 | 1 | 2 | $r{duration} = 0; | |||
| 171 | 1 | 2 | p(%r, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'}; | |||
| 172 | 1 | 1 | p %r, string_max => $string_max; | |||
| 173 | 1 | 2921 | return \%r; | |||
| 174 | } else { | |||||
| 175 | 21 | 22 | $r{done} = 'not yet'; | |||
| 176 | } | |||||
| 177 | 21 | 28 | if ($r{'dry.run'}) { | |||
| 178 | 2 | 3 | say "\@ $c[1] line $c[2] in $r{dir} the command was going to be:"; | |||
| 179 | 2 | 5 | say colored(['red on_black'], "\"$args->{cmd}\""); | |||
| 180 | 2 | 42 | say 'But this is a dry run'; | |||
| 181 | 2 | 2 | say '-------------'; | |||
| 182 | 2 | 2 | $r{duration} = 0; | |||
| 183 | 2 | 6 | return \%r; | |||
| 184 | } | |||||
| 185 | 19 | 35 | my $t0 = Time::HiRes::time(); | |||
| 186 | 19 | 12 | my $status; | |||
| 187 | ($r{stdout}, $r{stderr}, $status) = capture { | |||||
| 188 | 19 | 84412 | system( $args->{cmd} ); | |||
| 189 | 19 | 551 | }; | |||
| 190 | 19 | 9165 | my $t1 = Time::HiRes::time(); | |||
| 191 | 19 | 35 | $r{duration} = $t1-$t0; | |||
| 192 | # Decode the raw wait status. On Unix the low 7 bits hold the death | |||||
| 193 | # signal and the high byte holds the exit code. The signal MUST be read | |||||
| 194 | # from the raw status *before* shifting -- the old code shifted first and | |||||
| 195 | # then did ($exit & 127), so $r{signal} was always 0 and could never | |||||
| 196 | # detect a kill by signal 9/15. Windows has no POSIX signals, and a -1 | |||||
| 197 | # return from system() means the command never launched. | |||||
| 198 | 19 | 108 | if (!defined $status || $status == -1) { | |||
| 199 | 0 | 0 | $r{'exit'} = -1; | |||
| 200 | 0 | 0 | $r{signal} = 0; | |||
| 201 | } elsif ($^O eq 'MSWin32') { | |||||
| 202 | 0 | 0 | $r{signal} = 0; | |||
| 203 | 0 | 0 | $r{'exit'} = $status >> 8; | |||
| 204 | } else { | |||||
| 205 | 19 | 62 | $r{signal} = $status & 127; # FIX: taken from raw status, not from $exit | |||
| 206 | 19 | 59 | $r{'exit'} = $status >> 8; | |||
| 207 | } | |||||
| 208 | 19 | 43 | foreach my $std ('stderr', 'stdout') { | |||
| 209 | 38 | 75 | $r{$std} =~ s/\s+$//; # remove trailing whitespace/newline | |||
| 210 | 38 | 58 | $string_max = max($string_max, length $r{$std}); | |||
| 211 | } | |||||
| 212 | 19 | 41 | $r{done} = 'now'; | |||
| 213 | 19 | 16 | $r{'will.do'} = 'done'; | |||
| 214 | 19 5 | 27 28 | my @missing_output_files = grep {not -f -r $_} @output_files; | |||
| 215 | 19 | 36 | if (scalar @missing_output_files > 0) { | |||
| 216 | 1 | 7 | $r{'will.do'} = 'FAILED'; | |||
| 217 | 1 | 9 | say STDERR "this input to $current_sub:"; | |||
| 218 | 1 | 21 | p $args; | |||
| 219 | 1 0 | 3485 0 | say {$args->{'log.fh'}} "this input to $current_sub:" if defined $args->{'log.fh'}; | |||
| 220 | 1 | 4 | p($args, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'}; | |||
| 221 | 1 | 3 | say STDERR 'has these output files missing:'; | |||
| 222 | 1 0 | 1 0 | say {$args->{'log.fh'}} 'has these output files missing:' if defined $args->{'log.fh'}; | |||
| 223 | 1 | 3 | p @missing_output_files; | |||
| 224 | 1 | 1804 | p(@missing_output_files, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'}; | |||
| 225 | 1 | 15 | p %r, string_max => $string_max; | |||
| 226 | 1 | 3980 | p(%r, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'}; | |||
| 227 | 1 | 3 | if ($r{'die'}) { # use the resolved value (defaults to 1), not the raw arg | |||
| 228 | 0 | 0 | die 'those above files should have been made but are missing'; | |||
| 229 | } else { | |||||
| 230 | 1 | 2 | say STDERR 'those above files should have been made but are missing'; | |||
| 231 | } | |||||
| 232 | } | |||||
| 233 | 19 5 | 23 25 | %output_file_size = map {$_ => -s $_} @output_files; | |||
| 234 | 19 | 21 | $r{'output.file.size'} = \%output_file_size; | |||
| 235 | # p %output_file_size; | |||||
| 236 | 19 5 | 19 34 | my @files_with_zero_size = grep { ($output_file_size{$_} // 0) == 0 } @output_files; | |||
| 237 | 19 | 26 | if (scalar @files_with_zero_size > 0) { | |||
| 238 | 2 | 5 | p @files_with_zero_size; | |||
| 239 | 2 | 2233 | warn 'the above output files have 0 size.'; | |||
| 240 | } | |||||
| 241 | 19 | 346 | p(%r, output => $args->{'log.fh'}) if defined $args->{'log.fh'}; | |||
| 242 | 19 | 3540 | if (($r{'die'}) && ($r{'exit'} != 0)) { | |||
| 243 | 1 | 3 | $r{'will.do'} = 'FAILED'; | |||
| 244 | 1 | 5 | p %r, string_max => $string_max; | |||
| 245 | 1 | 3297 | die "\"$args->{cmd}\" failed from $c[1] line $c[2]" | |||
| 246 | } | |||||
| 247 | 18 | 78 | p %r, string_max => $string_max; | |||
| 248 | 18 | 93943 | return \%r; | |||
| 249 | } | |||||
| 250 | 1; | |||||
| 251 | ||||||
| 252 | =encoding utf8 | |||||
| 253 | ||||||
| 254 | A tiny workflow manager and logger for Perl, like SnakeMake or NextFlow, but in pure Perl and aimed at making long, error-prone shell pipelines easy to B<debug> and B<reproduce>. | |||||
| 255 | ||||||
| 256 | Every step is a single C<task()> call. SimpleFlow checks the inputs before a | |||||
| 257 | command runs and the outputs after, times the command, captures its C<stdout>, | |||||
| 258 | C<stderr>, exit code and signal, optionally logs a full structured record, and | |||||
| 259 | skips work that has already been done. | |||||
| 260 | ||||||
| 261 | Two subroutines are exported by default: L</"task"> and L</"say2">. | |||||
| 262 | ||||||
| 263 - 650 | =head1 Install
With a CPAN client:
cpanm SimpleFlow
Or from a checkout:
perl Makefile.PL
make
make test
make install
=head1 Synopsis
The simplest useful case: run a command and confirm it produced its output:
use SimpleFlow qw(task say2);
my $t = task({
cmd => 'which ls',
'output.files' => '/tmp/AFK3mnEK8L.log',
});
C<task> returns a hash reference describing exactly what happened:
{
cmd "which ls",
die 1,
dir "/home/con/Scripts/SimpleFlow",
done "now",
dry.run 0,
duration 0.00191903114318848,
exit 0,
note "",
output.files [
[0] "/tmp/AFK3mnEK8L.log"
],
overwrite 1,
signal 0,
source.file "t/01.t",
source.line 29,
stderr "",
stdout "/usr/bin/ls",
will.do "done"
}
> B<Portability note.> SimpleFlow runs whatever shell command you give it via
> C<system()>, so the I<commands themselves> are your responsibility to keep
> cross-platform (e.g. C<which ls> is Unix-only). SimpleFlow's own behaviour
> exit/signal decoding and coloured output is cross-platform; see the
> L<change log|/"Change log">.
=head1 C<task>
my $result = task(\%args);
Runs one shell command with checking, timing, capture and logging. Takes a
B<single hash reference>; the only required key is C<cmd>.
=head2 Arguments
=begin html
<table>
<thead>
<tr>
<th>Key</th>
<th>Type</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>cmd</code></td>
<td>scalar</td>
<td><code>undef</code></td>
<td><b>Required.</b> The shell command to run.</td>
</tr>
<tr>
<td><code>die</code></td>
<td>bool (<code>0</code>/<code>1</code>)</td>
<td><code>1</code></td>
<td>Die if the command fails (non-zero exit) or an output file is missing. Set to <code>0</code> to warn and continue instead.</td>
</tr>
<tr>
<td><code>dry.run</code></td>
<td>bool</td>
<td><code>0</code></td>
<td>Print the command (and log it) but do not execute it.</td>
</tr>
<tr>
<td><code>input.files</code></td>
<td>scalar or array</td>
<td><code>undef</code></td>
<td>File(s) that must exist and be readable <b>before</b> running; otherwise <code>task</code> dies.</td>
</tr>
<tr>
<td><code>output.files</code></td>
<td>scalar or array</td>
<td><code>undef</code></td>
<td>File(s) expected to exist <b>after</b> running; used both for the missing-output check and for skip detection.</td>
</tr>
<tr>
<td><code>log.fh</code></td>
<td>open filehandle</td>
<td><code>undef</code></td>
<td>If given, the full result record is also written here. Must be a real, open filehandle.</td>
</tr>
<tr>
<td><code>note</code></td>
<td>scalar</td>
<td><code>''</code></td>
<td>Free-text note copied into the result and the log.</td>
</tr>
<tr>
<td><code>overwrite</code></td>
<td>bool</td>
<td><code>0</code></td>
<td>If false and all <code>output.files</code> already exist, the command is skipped. Set true to always run.</td>
</tr>
</tbody>
</table>
=end html
Passing an unrecognised key, an empty filename, or a non-filehandle C<log.fh>
causes C<task> to die: these are usually mistakes worth catching early.
=head2 Return value
C<task> always returns a hash reference. The fields below are present after a
normal run; the L<skip|/"Skipping completed work"> and L<dry-run|/"Dry runs"> paths
omit the execution-only fields (C<exit>, C<signal>, C<stdout>, C<stderr>).
=begin html
<table>
<thead>
<tr>
<th>Field</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>cmd</code></td>
<td>The command that was run.</td>
</tr>
<tr>
<td><code>dir</code></td>
<td>Working directory at execution time.</td>
</tr>
<tr>
<td><code>done</code></td>
<td><code>"now"</code> (just ran), <code>"before"</code> (skipped, outputs already existed), or <code>"not yet"</code> (dry run).</td>
</tr>
<tr>
<td><code>will.do</code></td>
<td><code>"done"</code>, <code>"no"</code> (skipped), <code>"no: dry run"</code>, or <code>"FAILED"</code>.</td>
</tr>
<tr>
<td><code>duration</code></td>
<td>Wall-clock seconds the command took (<code>0</code> for skips/dry runs).</td>
</tr>
<tr>
<td><code>exit</code></td>
<td>Exit code of the command (<code>-1</code> if it could not be launched).</td>
</tr>
<tr>
<td><code>signal</code></td>
<td>Signal number if the command process was killed by a signal, else <code>0</code>. Always <code>0</code> on Windows (no POSIX signals).</td>
</tr>
<tr>
<td><code>stdout</code>, <code>stderr</code></td>
<td>Captured output, with trailing whitespace stripped.</td>
</tr>
<tr>
<td><code>die</code>, <code>dry.run</code>, <code>overwrite</code>, <code>note</code></td>
<td>The (defaulted) argument values used.</td>
</tr>
<tr>
<td><code>output.files</code></td>
<td>Array ref of the output files (a scalar argument is normalised to a one-element array).</td>
</tr>
<tr>
<td><code>output.file.size</code></td>
<td>Hash of <code>filename => size in bytes</code> for the outputs.</td>
</tr>
<tr>
<td><code>input.files</code></td>
<td>The input argument, as given (present only if you passed <code>input.files</code>).</td>
</tr>
<tr>
<td><code>input.file.size</code></td>
<td>Hash of <code>filename => size in bytes</code> for the inputs (present only if you passed <code>input.files</code>).</td>
</tr>
<tr>
<td><code>source.file</code>, <code>source.line</code></td>
<td>Where in <i>your</i> code the <code>task</code> was called: handy when debugging a long pipeline.</td>
</tr>
</tbody>
</table>
=end html
=head2 Skipping completed work
If C<overwrite> is false (the default) and every file in C<output.files> already
exists, C<task> does B<not> re-run the command. This makes pipelines
restartable: re-running the script picks up where it left off.
open my $log, '>', 'logfile.txt';
my $t = task({
cmd => 'gmx grompp -f em.mdp -c box.gro -p topol.top -o em.tpr',
'input.files' => ['em.mdp', 'box.gro', 'topol.top'],
'output.files' => 'em.tpr',
'log.fh' => $log,
});
close $log;
On the first run C<done> is C<"now">; on a re-run (with C<em.tpr> present) C<done>
is C<"before"> and C<will.do> is C<"no">. Pass C<< overwrite =E<gt> 1 >> to force it.
=head2 Dry runs
Useful for inspecting a pipeline without executing anything expensive:
my $t = task({
cmd => 'a long-running, time-consuming command',
'dry.run' => 1,
'log.fh' => $fh,
});
The command is printed (and logged) but not run; C<will.do> is C<"no: dry run">.
=head2 Failure behaviour
By default (C<< die =E<gt> 1 >>) C<task> dies if the command exits non-zero or if any
declared C<output.files> are missing afterwards, so a broken step stops the
pipeline immediately. With C<< die =E<gt> 0 >>, C<task> instead warns and returns its
result hash (with C<< will.do =E<gt> "FAILED" >>), letting you decide what to do.
=head2 C<say2>
say2($message, $filehandle);
"Say to two places": prints C<$message> to standard output B<and> to the given
log filehandle, prefixed with the calling file and line number so log entries
are traceable. The filehandle must be open, or C<say2> dies.
open my $log, '>', 'run.log';
say2('starting equilibration', $log); # -> STDOUT and run.log
close $log;
=head1 Dependencies
Core/runtime modules used by SimpleFlow:
=over
=item * L<Capture::Tiny> captures C<stdout>/C<stderr>
=item * L<Data::Printer> (C<DDP>) pretty result/record printing
=item * L<Devel::Confess> better backtraces on death
=item * L<Term::ANSIColor> coloured terminal output
=item * C<List::Util>, C<Scalar::Util>, C<Time::HiRes>, C<Cwd> core utilities
=back
The test suite additionally uses C<Test::More> and
L<Test::Exception>.
=head1 Change log
=head2 0.14 (2026-06-29) (Claude Opus 4.8 helped)
=head3 C<task>
=over
=item * B<New:> accepts a flat key/value list as well as a hash ref â
C<< task(cmd =E<gt> ...) >> and C<< task({ cmd =E<gt> ... }) >> are now equivalent. A lone
non-hashref scalar or any odd-length argument list is fatal.
=item * B<Bug fix:> the default C<< die =E<gt> 1 >> was ignored when checking for missing
C<output.files>. The block tested the raw C<< $args-E<gt>{'die'} >> (undef when the
caller omitted it) instead of the resolved C<$r{'die'}>, so a command that
failed to produce its declared outputs only warned instead of dying. Now
consistent with the exit-code check.
=item * B<Bug fix:> removed a stray C<)> (and an extraneous leading space) from the
"command is" line written to the log file; it now matches the on-screen form.
=item * B<Bug fix:> C<length $_ == 0> could throw a fatal uninitialized-value warning
(under C<< warnings FATAL =E<gt> 'all' >>) on an undef element of the C<input.files>
array branch and the C<output.files> empty-name check. Both now guard with
C<(defined $_) && (length $_ == 0)>, matching the C<input.files> scalar branch.
=back
=head2 0.13 (2026-06-11)
=head3 Fixed (Claude Opus 4.8 helped)
=over
=item * B<Exit status and signal are now decoded correctly.> C<task()> previously
computed the exit code (C<< $status E<gt>E<gt> 8 >>) and I<then> derived the signal as
C<$exit & 127>. Because the signal lives in the low byte of the raw wait
status, which C<< E<gt>E<gt> 8 >> discards the C<signal> field was always wrong: a clean
C<exit 42> was reported as C<signal 42>, and a process actually killed by a
signal reported C<signal 0>. The signal is now read from the raw status before
shifting, so C<exit> and C<signal> are independent and accurate.
=item * B<< No longer dies on a missing output file when C<< die =E<gt> 0 >>. >> The zero-size
check did C<(-s $file) == 0>, which is C<undef == 0> when a declared output file
is absent. Under C<< use warnings FATAL =E<gt> 'all' >> that "uninitialized value"
warning was fatal, so a task that was meant to I<warn> about missing output
(with C<< die =E<gt> 0 >>) crashed instead. Missing sizes are now treated as C<0>, so
the task warns and returns its result hash as intended.
=item * B<< The "already done" result is now logged with its C<duration>. >> In the
short-circuit path (output files already exist), C<duration> was set I<after>
the record was written to the log, so the logged hash was missing it; the
duplicate C<< done =E<gt> 'before' >> assignment was also removed.
=back
=head3 Changed / Windows support
=over
=item * B<Portable exit-status handling.> Decoding now branches on C<$^O>: Windows has
no POSIX signals (C<signal> is reported as C<0> there), and a C<system()> that
fails to launch the command (C<-1>) yields C<< exit =E<gt> -1 >> instead of a garbage
value from shifting C<-1>.
=item * B<ANSI colour is disabled on the legacy Windows console.> C<Term::ANSIColor>
output is suppressed on C<MSWin32> unless an ANSI-capable terminal is detected
(Windows Terminal, ConEmu, or ANSICON), so C<cmd.exe> no longer prints raw
escape sequences and redirected logs stay clean. Unix and modern Windows
terminals are unaffected.
=back
=head3 Tests
=over
=item * Rewrote C<t/01.t> to be cross-platform: shell commands now invoke the running
Perl interpreter (C<"$^X" -e ...>) instead of Unix-only tools (C<which>, C<ls>,
C<ln>, C<cp>), and temp files use the system temp directory instead of a
hard-coded C</tmp>.
=item * Added regression tests for both fixed bugs (exit/signal decoding; surviving a
missing output file with C<< die =E<gt> 0 >>).
=item * Added coverage for the C<note> field, the C<input.file.size> / C<output.file.size>
hashes, scalar-vs-array normalisation of C<input.files> / C<output.files>, the
C<dir> / C<source.file> / C<source.line> metadata, captured C<stdout> / C<stderr>
(including trailing-whitespace stripping), and argument validation (missing
C<cmd>, unknown keys, bad C<log.fh>, missing input files).
=back
=head2 0.12
exit code now matches what shell would show it as; signal now appears
=head2 0.11
max string length now corresponds to max of output strings, no more truncated output
added List::Util dependency for string length maxes
memory size now shows when output
directory is now output during dry runs | |||||