Friday, August 07, 2015

Accepting Input from Multiple Sources

One of the corners I often paint myself into when developing a tool is only accepting one type of input, usually STDIN, the standard input stream, like a pipeline (ex: cat fruit.txt | grep apple) or a redirect (ex: grep apple < fruit.txt)

What inevitably happens is I end up wanting the tool to work like any Unix tool and accept different kinds of input (filenames or arguments on the command line, for example.)

Finally I got fed up with it and added a function called multi_input() to my library. Here is how it works:

First, the setup:

$ cat >meats
chicken
beef
^D
$ cat >fruits
apple
orange
banana
^D
$ cat >vegetables
carrot
lettuce
broccoli
cauliflower
^D
$ cat >a.out
this is just my
default input file
^D

To illustrate use of the function, I just reverse the input to do something "interesting" with it. The operative code is:

  1. my $input = multi_input();
  2. my $reversed = reverse $input;
  3. print "$input\n";
  4. print "$reversed\n";

So now I can interact with the tool in a variety of ways, starting with my "usual" way, STDIN:

$ ./reverse.pl < vegetables
current_input_type is: STDIN
carrot
lettuce
broccoli
cauliflower

rewolfiluac
iloccorb
ecuttel
torrac

Or STDIN by way of a pipe (this is the same mechanism in the code, but just to give another example):

$ cat fruits | ./reverse.pl
current_input_type is: STDIN
apple
orange
banana

ananab
egnaro
elppa

Or filenames provided on the command line:

$ ./reverse.pl meats fruits
current_input_type is: FILEARGS
chicken
beef
apple
orange
banana

ananab
egnaro
elppa
feeb
nekcihc

Or input provided on the command line:

$ ./reverse.pl this is not a list of filenames
current_input_type is: ARGS
this is not a list of filenames
semanelif fo tsil a ton si siht

And finally, the ultimate lazy, my default input file a.out:

$ ./reverse.pl
current_input_type is: DEFAULT
this is just my
default input file

elif tupni tluafed
ym tsuj si siht

Here is the full code listing with comments:

  1. #!/usr/bin/perl
  2. use strict;
  3. use warnings;
  4. use Term::ReadKey; # for ReadMode() below
  5. sub multi_input {
  6. my $input = '';
  7. my $VERBOSE = 1;
  8. my %INPUT_TYPE = ( # names for self-documenting code
  9. NONE => 0,
  10. ARGS => 1,
  11. FILEARGS => 2,
  12. STDIN => 3,
  13. DEFAULT => 4,
  14. );
  15. my %INPUT_LABEL = reverse %INPUT_TYPE; # allow lookup by number
  16. my $current_input_type = $INPUT_TYPE{NONE};
  17. # I could have done this all in one "shot" but I wanted to keep the
  18. # detection of input type separate from the processing of input
  19. my $char;
  20. if ( @ARGV ) {
  21. # Note that a filename typo will result in processing of the command
  22. # line like it is normal input, but that won't matter in this example.
  23. if ( -f $ARGV[0] ) {
  24. $current_input_type = $INPUT_TYPE{FILEARGS};
  25. }
  26. else {
  27. $current_input_type = $INPUT_TYPE{ARGS};
  28. }
  29. }
  30. else {
  31. # Code from Perl Cookbook. We peek into STDIN stream to see if
  32. # anything's there. The read still counts, though, so we need to save
  33. # $char. perldoc Term::ReadKey for information on ReadMode() and
  34. # ReadKey()
  35. ReadMode('cbreak');
  36. if (defined ($char = ReadKey(-1)) ) {
  37. $current_input_type = $INPUT_TYPE{STDIN};
  38. }
  39. else {
  40. $current_input_type = $INPUT_TYPE{DEFAULT};
  41. }
  42. ReadMode('normal');
  43. }
  44. warn "current_input_type is: $INPUT_LABEL{$current_input_type}\n"
  45. if $VERBOSE;
  46. if ( $current_input_type == $INPUT_TYPE{FILEARGS} ) {
  47. local $/; # Slurp the whole file in at once, not line-by-line
  48. for my $file (@ARGV) {
  49. open(my $ifh, '<', $file) or die "Can't open $file: $!";
  50. $input .= <$ifh>;
  51. close($ifh) || warn "close failed: $!";
  52. }
  53. }
  54. elsif ( $current_input_type == $INPUT_TYPE{ARGS} ) {
  55. $input = join ' ', @ARGV;
  56. }
  57. elsif ( $current_input_type == $INPUT_TYPE{STDIN} ) {
  58. # Slurp all STDIN at once, not line-by-line
  59. $input = $char . do { local $/; <STDIN> };
  60. }
  61. else {
  62. my $file = "a.out";
  63. open(my $ifh, '<', $file) or die "Can't open $file: $!";
  64. $input = do { local $/; <$ifh> };
  65. close($ifh) || warn "close failed: $!";
  66. }
  67. return $input;
  68. }
  69. my $input = multi_input();
  70. my $reversed = reverse $input;
  71. print "$input\n";
  72. print "$reversed\n";

5 comments:

Jake Coughlin said...

laugh! i've been writing Perl for 15 years or so, and this blew my mind:

my %INPUT_LABEL = reverse %INPUT_TYPE;

i've been using hash slices for that. your way is BRILLIANT for simply saying exactly what it's doing!

Joel Maslak said...

@Jake: Agreed - that's much better than my foreach loops or map expressions. And I've been programming Perl for +20 years and didn't know about it.

Neil Bowers said...

If you look at perldoc reverse it talks about this use to invert a hash. As it says, you need to to watch out for cases where multiple keys in the original hash have the same value: only one will turn up in the inverted hash.

David M. Bradford said...

Thanks for the comments. More than anything I take comfort in the fact that there are others like me, who after many years of coding, find themselves occasionally wondering, "How the hell did I not know that?!?" :)

Matthew Persico said...

17 years of slicing hashes to invert them for me.