11408
Comment: How to add testing capability to a programs: New section
|
9087
Don't always just echo commands...
|
Deletions are marked like this. | Additions are marked like this. |
Line 1: | Line 1: |
#pragma section-numbers 2 | |
Line 3: | Line 4: |
Variables hold data. Functions hold code. Don't put code inside variables! There are many situations in which people try to shove commands, or command arguments, into variables and then run them. Each case needs to be handled separately. <<TableOfContents>> === Things that do not work === |
|
Line 4: | Line 11: |
Line 5: | Line 13: |
# Non-working example args="-s 'The subject' $address" mail $args < $body |
# Example of BROKEN code, DON'T USE THIS. args=$address1 if [[ $subject ]]; then args+=" -s $subject" fi mail $args < "$body" |
Line 10: | Line 21: |
This fails because of WordSplitting. When {{{$args}}} is expanded, it becomes four words. {{{'The}}} is the second word, and {{{subject'}}} is the third word. | Adding quotes won't help, either: |
Line 12: | Line 23: |
So, how do we do this? That all depends on what "this" is! | {{{ # Example of BROKEN code, DON'T USE THIS. args="$address1 $address2" if [[ $subject ]]; then args+=" -s '$subject'"; fi mail $args < "$body" }}} |
Line 14: | Line 30: |
There are at least three situations in which people try to shove commands, or command arguments, into variables and then run them. Each case needs to be handled separately. | This fails because of WordSplitting and because the single quotes inside the variable are literal, not syntactical. If `$subject` contains internal whitespace, it will be split at those points. The `mail` command will receive `-s` as one argument, then the first word of the subject (with a literal `'` in front of it) as the next argument, and so on. Read [[Arguments]] to get a better understanding of how the shell figures out what the arguments in your statement are. Here's another thing that won't work: {{{ # BROKEN code. Do not use! redirs=">/dev/null 2>&1" if ((debug)); then redirs=; fi some command $redirs }}} Here's yet another thing that won't work: {{{ # BROKEN code. Do not use! runcmd() { if ((debug)); then echo "$@"; fi; "$@"; } }}} The `runcmd` function can only handle '''simple commands''' with no redirections. It can't handle redirections, pipelines, for/while loops, if statements, etc. Now let's look at how we can perform some of these tasks. === I'm trying to save a command so I can run it later without having to repeat it each time === Just use a function: {{{ pingMe() { ping -q -c1 "$HOSTNAME" } [...] if pingMe; then .. }}} === I only want to pass options if the runtime data needs them === You can use the `${var:+..}` [[BashFAQ/073|parameter expansion]] for this: {{{ ping -q ${count:+-c "$count"} "$HOSTNAME" }}} Now the `-c` option (with its `"$count"` argument) is only added to the command when `$count` is not empty. Notice the quoting: No quotes around `${var:+...}` but quotes on expansions INSIDE! This would also work well for our `mail` example: {{{ addresses=("$address1" "$address2") mail ${subject:+-s "$subject"} "${addresses[@]}" < body }}} === I want to generalize a task, in case the low-level tool changes later === Again, variables hold data; functions hold code. In the `mail` example, we've got hard-coded dependence on the syntax of the Unix `mail` command. The version in the previous section is an improvement over the original broken code, but what if the internal company mail system changes? Having several calls to `mail` scattered throughout the script complicates matters in this situation. What you probably should be doing, '''paying very close attention at how to quote your expansions''', is this: {{{ # Bash 3.1 # Send an email to someone. # Reads the body of the mail from standard input. # # sendto subject address [address ...] # sendto() { # Used to be standard mail, but the fucking HR department # said we have to use this crazy proprietary shit.... # mailx -s "$@" local subject=$1 shift local addr addrs=() for addr; do addrs+=(--recipient="$addr"); done MailTool --subject="$subject" "${addrs[@]}" } sendto "The Subject" "$address" <"$bodyfile" }}} The original implementation uses `mailx(1)`, a standard Unix command. Later, this is commented out and replaced by something called `MailTool`, which was made up on the spot for this example. But it should serve to illustrate the concept: the function's invocation is unchanged, even though the back-end tool changes. |
Line 17: | Line 116: |
The root of the issue described above is that you need a way to maintain each argument as a separate word, even if that argument contains spaces. Quotes won't do it, but an [[BashFAQ/005|array]] will. | |
Line 19: | Line 117: |
Suppose your script wants to send email. You might have places where you want to include a subject, and others where you don't. The part of your script that sends the mail might check a variable named `subject` to determine whether you need to supply additional arguments to the `mail` command. A naive programmer may come up with something like this: | The root of the issue described above is that you need a way to maintain each argument as a separate word, even if that argument contains spaces. Quotes won't do it, but an [[BashFAQ/005|array]] will. (We saw a bit of this in the previous section, where we constructed the `addrs` array on the fly.) If you need to create a command dynamically, put each argument in a separate element of an array. A shell with arrays (like Bash) makes this ''much'' easier. POSIX sh has no arrays, so the closest you can come is to build up a list of elements in the positional parameters. Here's a POSIX sh version of the `sendto` function from the previous section: |
Line 21: | Line 122: |
# Don't do this. args=$recipent if [[ $subject ]]; then args+=" -s $subject" |
# POSIX sh # Usage: sendto subject address [address ...] sendto() { subject=$1 shift first=1 for addr; do if [ "$first" = 1 ]; then set --; first=0; fi set -- "$@" --recipient="$addr" done if [ "$first" = 1 ]; then echo "usage: sendto subject address [address ...] return 1 |
Line 26: | Line 136: |
mail $args < $bodyfilename | MailTool --subject="$subject" "$@" } |
Line 29: | Line 140: |
As we have seen, this approach fails when the `subject` contains whitespace. It simply is not robust enough. | Note that we overwrite the positional parameters ''inside'' a loop that is iterating over the previous set of positional parameters (because we can't make a second array, not even to hold a copy of the original parameters). This appears to work in at least 3 different `/bin/sh` implementations (tested in Debian's dash, HP-UX's sh and OpenBSD's sh). |
Line 31: | Line 142: |
As such, if you really need to create a command dynamically, put each argument in a separate element of an array, like so: {{{ # Working example, bash 3.1 or higher args=("$recipient") if [[ $subject ]]; then args+=(-s "$subject") fi mail "${args[@]}" < "$bodyfilename" }}} (See [[BashFAQ/005|FAQ #5]] for more details on array syntax.) Often, this question arises when someone is trying to use {{{dialog}}} to construct a menu on the fly. The {{{dialog}}} command can't be hard-coded, because its parameters are supplied based on data only available at run time (e.g. the number of menu entries). For an example of how to do this properly, see [[BashFAQ/040|FAQ #40]]. === I want to generalize a task, in case the low-level tool changes later === You generally do NOT want to put command names or command options in variables. Variables should contain the data you are trying to pass to the command, like usernames, hostnames, ports, text, etc. They should NOT contain options that are specific to one certain command or tool. Those things belong in ''functions''. In the mail example, we've got hard-coded dependence on the syntax of the Unix `mail` command -- and in particular, versions of the `mail` command that permit the subject to be specified ''after'' the recipient, which may not always be the case. Someone maintaining the script may decide to fix the syntax so that the recipient appears last, which is the most correct form; or they may replace `mail` altogether due to internal company mail system changes, etc. Having several calls to `mail` scattered throughout the script complicates matters in this situation. What you probably should be doing, is this: {{{ # POSIX # Send an email to someone. # Reads the body of the mail from standard input. # # sendto address [subject] # sendto() { # mail ${2:+-s "$2"} "$1" MailTool ${2:+--subject="$2"} --recipient="$1" } sendto "$address" "The Subject" < "$bodyfile" }}} Here, the [[BashFAQ/073|parameter expansion]] checks if `$2` (the optional subject) has expanded to anything. If it has, the expansion adds the `-s "$2"` to the `mail` command. If it hasn't, the expansion doesn't add the `-s` option at all. The original implementation uses `mail(1)`, a standard Unix command. Later, this is commented out and replaced by something called `MailTool`, which was made up on the spot for this example. But it should serve to illustrate the concept: the function's invocation is unchanged, even though the back-end tool changes. === I want a log of my script's actions === Another reason people attempt to stuff commands into variables is because they want their script to print each command before it runs it. If that's all you want, then simply use the {{{set -x}}} command, or invoke your script with {{{#!/bin/bash -x}}} or {{{bash -x ./myscript}}}. Note that you can turn it off and back on inside the script with {{{set +x}}} and {{{set -x}}}. |
Another example of this is using {{{dialog}}} to construct a menu on the fly. The {{{dialog}}} command can't be hard-coded, because its parameters are supplied based on data only available at run time (e.g. the number of menu entries). For an example of how to do this properly, see [[BashFAQ/040|FAQ #40]]. |
Line 76: | Line 146: |
Some people get into trouble because they want to have their script print their commands ''including redirections'' before it runs them. {{{set -x}}} shows the command without redirections. People try to work around this by doing things like: | === I want a log of my script's actions === Another reason people attempt to stuff commands into variables is because they want their script to print each command before it runs it. If that's all you want, then simply use {{{set -x}}} command, or invoke your script with {{{#!/bin/bash -x}}} or {{{bash -x ./myscript}}}. |
Line 78: | Line 151: |
# Non-working example command="mysql -u me -p somedbname < file" ((DEBUG)) && echo "$command" "$command" }}} (This is so common that I include it explicitly, even though it's repeating what I already wrote.) Once again, ''this does not work''. Not even using an array works here. The only thing that would work is rigorously escaping the command to be sure ''no'' metacharacters will cause serious security problems, and then using `eval` or `sh` to re-read the command. '''Please don't do that!''' If your head is SO far up your ass that you still think you need to write out every command you're about to run before you run it, AND that you must include all redirections, then just do this: {{{ # Working example echo "mysql -u me -p somedbname < file" mysql -u me -p somedbname < file }}} Don't use a variable at all. Just copy and paste the command, wrap an extra layer of quotes around it (sometimes tricky), and stick an `echo` in front of it. My personal recommendation would be just to use {{{set -x}}} and not worry about it. === How to add testing capability to a programs === [By Jari Aalto] If you are developing a longer program, the possibility to test (what would it do) before actual use would can help to spot problems before they happen in real. Here we define function '''Run()''' that is used to proxy all commands. If the ''TEST'' mode is on, the commands are not executed for real but only printed to the screen for review. The ''test mode'' is activated with program's command line option {{{-t}}} which is read read via Bash's built-in {{{getopt}}}. The heart of the demonstration is function 'Demo()' where we see how the calls make use of testing. Pay attention to how the quotes are used when command contains any shell meta characters. Notice also how you need the 'Run' call also inside subshell calls. The represetation of subshell calls is limited under ''test mode'' as you can see from the last output. You can add similar testing approach to your programs by: 1) copying the '''Run()''' 2) utilizing variable ''TEST'' 3) modifying all shell command calls to go through '''Run()'''. In practice it is very difficult to completely put shell program under pure testing mode, because programs may use very complex shell structures and depend on outputs that are generated by previous commands. Still, the possibility to improve testing capabilities from nothing gives a better chance to be able to review the program execution before anything is done for real. The output: {{{ $ bash test-demo.sh -t test-demo.sh -- Demonstrate how testing feature can be implemented in program # DEMO: a command ls -l # DEMO: a command with pipe ls -l | sort # DEMO: a command with pipe and redirection ls -l | sort > /tmp/jaalto.1396-ls.lst # DEMO: a command with pipe and redirection using quotes ls -l | sort > "/tmp/jaalto.1396-ls.lst" # DEMO: a command and subshell call. echo ls -l |
if ((DEBUG)); then set -x; fi mysql -u me -p somedbname < file ... |
Line 127: | Line 156: |
Note that you can turn it off and back on inside the script with {{{set +x}}} and {{{set -x}}}. Some people get into trouble because they want to have their script print their commands ''including redirections''. {{{set -x}}} shows the command without redirections. People try to work around this by doing things like: |
|
Line 129: | Line 161: |
#!/bin/bash # # test-demo.sh -- Demonstrate how testing feature can be implemented in program # # Copyright (C) 2009 Jari Aalto <jari.aalto@cante.net> # # License # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License as # published by the Free Software Foundation; either version 2 of # the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public License # along with program. If not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301, USA. # # Visit <http://www.gnu.org/copyleft/gpl.html> # # Description # # To enable debugging and testing capabilities in shell # scripts, add function Run() and use it to proxy all commands. # # Notes # # The functions in the program are "defined before used". It is only # possible to call a function (= command) if it exists (= is defined). # # There is explicit Main() where program starts. This follows # the convention of good programming style. By putting the code # inside functions, it also makes one think about modularity and # reusable components. |
# Non-working example command="mysql -u me -p somedbname < file" ((DEBUG)) && echo "$command" "$command" }}} (This is so common that I include it here explicitly.) |
Line 169: | Line 168: |
DESC="$0 -- Demonstrate how testing feature can be implemented in program" TEMPDIR=${TEMPDIR:-/tmp} TEMPPATH=$TEMPDIR/${LOGNAME:-foo}.$$ |
Once again, ''this does not work''. You can't make it work. Even the array trick won't work here. |
Line 173: | Line 170: |
# This variable is best to be undefined, not TEST="" or anything. unset TEST |
One way to log the whole command, without resorting to the use of `eval` or `sh` ([[BashFAQ/048|don't do that!]]), is the DEBUG [[SignalTrap|trap]]. A practical code example: |
Line 176: | Line 172: |
Help () { echo "\ $DESC |
{{{ trap 'printf %s\\n "$BASH_COMMAND" >&2' DEBUG }}} Assuming you're logging to standard error. |
Line 181: | Line 177: |
Available options: | Note that redirect representation by `BASH_COMMAND` may still be affected by [[https://lists.gnu.org/archive/html/bug-bash/2012-01/msg00096.html|this bug]]. |
Line 183: | Line 179: |
-d Debug. Before command is run, show it. -t Test mode. Show commands, do not really execute. |
If you STILL think you need to write out every command you're about to run before you run it, AND that you must include all redirections, AND you can't use a DEBUG trap, then just do this: |
Line 186: | Line 181: |
The -t option takes precedence over -d option." | {{{ # Working example echo "mysql -u me -p somedbname < file" mysql -u me -p somedbname < file }}} Don't use a variable at all. Just copy and paste the command, wrap an extra layer of quotes around it (can be tricky -- that's why we do '''not''' recommend trying to use `eval` here), and stick an `echo` in front of it. |
Line 188: | Line 188: |
exit ${1:-0} } |
However, consider that echoing your commands verbatim is really ugly. Why are you doing this? Are you debugging the script? If so, how is the output of `set -x` insufficient? All you have to do is find the bug and fix it. Surely you won't leave this debugging code in place once the bug has been fixed. |
Line 191: | Line 190: |
Run () { if [ "$TEST" ]; then echo "$*" return 0 fi |
If you intend to create a ''log'' of your script's actions, every time it is run, for accountability or other reasons, then that log should be human-readable. In that case, ''don't'' just echo your commands (especially if you have to bend over backwards to do so)! Write out meaningful (possibly even date-stamped) lines describing what you're doing. |
Line 198: | Line 192: |
eval "$@" } |
{{{ echo "Populating database table" mysql -u me -p somedbname < file }}} |
Line 201: | Line 197: |
Echo () { echo "# DEMO: $*" } Demo () { Echo "a command" Run ls -l Echo "a command with pipe" Run "ls -l | sort" Echo "a command with pipe and redirection" Run "ls -l | sort > $TEMPPATH-ls.lst" Echo "a command with pipe and redirection using quotes" Run "ls -l | sort > \"$TEMPPATH-ls.lst\"" # You need to put Run() call also into subshell, otherwise # it would be run "for real" and defeat the test mode. Echo "a command and subshell call." Run "echo $( Run ls -l )" } Main () { echo "$DESC" OPTIND=1 local arg while getopts "hdt" arg "$@" do case "$arg" in h) Help ;; t) TEST="test" ;; esac done # Remove found options from command line arguments. shift $(($OPTIND - 1)) # Run the demonstration Demo } Main "$@" # End of file }}} |
---- CategoryShell |
I'm trying to put a command in a variable, but the complex cases always fail!
Variables hold data. Functions hold code. Don't put code inside variables! There are many situations in which people try to shove commands, or command arguments, into variables and then run them. Each case needs to be handled separately.
Contents
-
I'm trying to put a command in a variable, but the complex cases always fail!
- Things that do not work
- I'm trying to save a command so I can run it later without having to repeat it each time
- I only want to pass options if the runtime data needs them
- I want to generalize a task, in case the low-level tool changes later
- I'm constructing a command based on information that is only known at run time
- I want a log of my script's actions
1. Things that do not work
Some people attempt to do things like this:
# Example of BROKEN code, DON'T USE THIS. args=$address1 if [[ $subject ]]; then args+=" -s $subject" fi mail $args < "$body"
Adding quotes won't help, either:
# Example of BROKEN code, DON'T USE THIS. args="$address1 $address2" if [[ $subject ]]; then args+=" -s '$subject'"; fi mail $args < "$body"
This fails because of WordSplitting and because the single quotes inside the variable are literal, not syntactical. If $subject contains internal whitespace, it will be split at those points. The mail command will receive -s as one argument, then the first word of the subject (with a literal ' in front of it) as the next argument, and so on.
Read Arguments to get a better understanding of how the shell figures out what the arguments in your statement are.
Here's another thing that won't work:
# BROKEN code. Do not use! redirs=">/dev/null 2>&1" if ((debug)); then redirs=; fi some command $redirs
Here's yet another thing that won't work:
# BROKEN code. Do not use! runcmd() { if ((debug)); then echo "$@"; fi; "$@"; }
The runcmd function can only handle simple commands with no redirections. It can't handle redirections, pipelines, for/while loops, if statements, etc.
Now let's look at how we can perform some of these tasks.
2. I'm trying to save a command so I can run it later without having to repeat it each time
Just use a function:
pingMe() { ping -q -c1 "$HOSTNAME" } [...] if pingMe; then ..
3. I only want to pass options if the runtime data needs them
You can use the ${var:+..} parameter expansion for this:
ping -q ${count:+-c "$count"} "$HOSTNAME"
Now the -c option (with its "$count" argument) is only added to the command when $count is not empty. Notice the quoting: No quotes around ${var:+...} but quotes on expansions INSIDE!
This would also work well for our mail example:
addresses=("$address1" "$address2") mail ${subject:+-s "$subject"} "${addresses[@]}" < body
4. I want to generalize a task, in case the low-level tool changes later
Again, variables hold data; functions hold code.
In the mail example, we've got hard-coded dependence on the syntax of the Unix mail command. The version in the previous section is an improvement over the original broken code, but what if the internal company mail system changes? Having several calls to mail scattered throughout the script complicates matters in this situation.
What you probably should be doing, paying very close attention at how to quote your expansions, is this:
# Bash 3.1 # Send an email to someone. # Reads the body of the mail from standard input. # # sendto subject address [address ...] # sendto() { # Used to be standard mail, but the fucking HR department # said we have to use this crazy proprietary shit.... # mailx -s "$@" local subject=$1 shift local addr addrs=() for addr; do addrs+=(--recipient="$addr"); done MailTool --subject="$subject" "${addrs[@]}" } sendto "The Subject" "$address" <"$bodyfile"
The original implementation uses mailx(1), a standard Unix command. Later, this is commented out and replaced by something called MailTool, which was made up on the spot for this example. But it should serve to illustrate the concept: the function's invocation is unchanged, even though the back-end tool changes.
5. I'm constructing a command based on information that is only known at run time
The root of the issue described above is that you need a way to maintain each argument as a separate word, even if that argument contains spaces. Quotes won't do it, but an array will. (We saw a bit of this in the previous section, where we constructed the addrs array on the fly.)
If you need to create a command dynamically, put each argument in a separate element of an array. A shell with arrays (like Bash) makes this much easier. POSIX sh has no arrays, so the closest you can come is to build up a list of elements in the positional parameters. Here's a POSIX sh version of the sendto function from the previous section:
# POSIX sh # Usage: sendto subject address [address ...] sendto() { subject=$1 shift first=1 for addr; do if [ "$first" = 1 ]; then set --; first=0; fi set -- "$@" --recipient="$addr" done if [ "$first" = 1 ]; then echo "usage: sendto subject address [address ...] return 1 fi MailTool --subject="$subject" "$@" }
Note that we overwrite the positional parameters inside a loop that is iterating over the previous set of positional parameters (because we can't make a second array, not even to hold a copy of the original parameters). This appears to work in at least 3 different /bin/sh implementations (tested in Debian's dash, HP-UX's sh and OpenBSD's sh).
Another example of this is using dialog to construct a menu on the fly. The dialog command can't be hard-coded, because its parameters are supplied based on data only available at run time (e.g. the number of menu entries). For an example of how to do this properly, see FAQ #40.
It's worth noting that you cannot put a pipeline command into an array variable and then execute it using the "${array[@]}" technique. The only way to store a pipeline in a variable would be to add (carefully!) a layer of quotes if necessary, store it in a string variable, and then use eval or sh to run the variable. This is not recommended, for security reasons. The same thing applies to commands involving redirection, if or while statements, and so on.
6. I want a log of my script's actions
Another reason people attempt to stuff commands into variables is because they want their script to print each command before it runs it. If that's all you want, then simply use set -x command, or invoke your script with #!/bin/bash -x or bash -x ./myscript.
if ((DEBUG)); then set -x; fi mysql -u me -p somedbname < file ...
Note that you can turn it off and back on inside the script with set +x and set -x.
Some people get into trouble because they want to have their script print their commands including redirections. set -x shows the command without redirections. People try to work around this by doing things like:
# Non-working example command="mysql -u me -p somedbname < file" ((DEBUG)) && echo "$command" "$command"
(This is so common that I include it here explicitly.)
Once again, this does not work. You can't make it work. Even the array trick won't work here.
One way to log the whole command, without resorting to the use of eval or sh (don't do that!), is the DEBUG trap. A practical code example:
trap 'printf %s\\n "$BASH_COMMAND" >&2' DEBUG
Assuming you're logging to standard error.
Note that redirect representation by BASH_COMMAND may still be affected by this bug.
If you STILL think you need to write out every command you're about to run before you run it, AND that you must include all redirections, AND you can't use a DEBUG trap, then just do this:
# Working example echo "mysql -u me -p somedbname < file" mysql -u me -p somedbname < file
Don't use a variable at all. Just copy and paste the command, wrap an extra layer of quotes around it (can be tricky -- that's why we do not recommend trying to use eval here), and stick an echo in front of it.
However, consider that echoing your commands verbatim is really ugly. Why are you doing this? Are you debugging the script? If so, how is the output of set -x insufficient? All you have to do is find the bug and fix it. Surely you won't leave this debugging code in place once the bug has been fixed.
If you intend to create a log of your script's actions, every time it is run, for accountability or other reasons, then that log should be human-readable. In that case, don't just echo your commands (especially if you have to bend over backwards to do so)! Write out meaningful (possibly even date-stamped) lines describing what you're doing.
echo "Populating database table" mysql -u me -p somedbname < file