1

I want to issue a bash command similar to this:

whiptail --title 'Select Database' --radiolist 'Select Database:' 10 80 2 \
  1 production off \
  2 localhost  on

Whiptail is rather particular about how the radio list values are specified. They must be provided each on their own line, as shown. Here is a good article on this question.

The list of databases is available in a variables called DBS, and ACTIVE_DB is the radiolist item to highlight in the whiptail dialog.

Here is my current effort for building the command line. It is probably way too convoluted.

DBS="production localhost"
ACTIVE_DB="localhost"
DB_COUNT="$( echo "$DBS" | wc -w )"

DB_LIST="$(
  I=1
  echo ""
  for DB in $DBS; do
    SELECTED="$( if [ "$DB" == "$ACTIVE_DB" ]; then echo " on"; else echo " off"; fi )"
    SLASH="$( if (( $I < $DB_COUNT )); then echo \\; fi )"
    echo "  $I $DB $SELECTED $SLASH"
    echo ""
    I=$(( I + 1 ))
  done
)"

OPERATION="whiptail \
  --title \"Select Database\" \
  --radiolist \
  \"Select Database:\" \
  10 80 $DB_COUNT \"${DB_LIST[@]}\""

eval "${OPERATION}"

I get fairly close. As you can see, the expansion contains single quotes that mess things up, and backslashes are missing at some EOLs:

set -xv 
++ whiptail --title 'Select Database' --radiolist 'Select Database:' 10 80 2 '
  1 production  off
  2 localhost  on '

The solution needs to provide a way to somehow know which selection the user made, or if they pressed ESC. ESC usually sets the return code to 255, so that should not be difficult, however this problem gets really messy when trying to retrieve the value of the user-selected radiolist item.

Mike Slinn
  • 7,705
  • 5
  • 51
  • 85
  • Command substitutions are *expensive*; they `fork()` off a whole new copy of the shell to run the code contained therein. It's much faster to write `if [ "$DB" = "$ACTIVE_DB" ]; then SELECTED=on; else SELECTED=off; fi` than to put the whole thing in a command substitution. – Charles Duffy May 01 '19 at 00:07
  • Beyond that, using `eval` as a whole is a Bad Idea -- build your content in an array and you don't need it at all. See [BashFAQ #50](http://mywiki.wooledge.org/BashFAQ/050) on why arrays are less prone to specific classes of bugs that it's easy to hit here, and [BashFAQ #48](http://mywiki.wooledge.org/BashFAQ/048) for a more general discussion of why `eval` specifically is bad news. – Charles Duffy May 01 '19 at 00:07
  • ...`"${DB_LIST[@]}"`, btw, is syntax that makes sense if `DB_LIST` were an array -- but it's *not* array in your code; it's just a single string (so your list expansion expands to only exactly one string, the same one you'd get from just `"$DB_LIST"`). – Charles Duffy May 01 '19 at 00:09
  • @CharlesDuffy I care not about expense. This only runs once every month or two. – Mike Slinn May 01 '19 at 00:13
  • *shrug*. Get in good habits and then you're doing something that's efficient when it *does* matter, as well as when it doesn't. You have juniors, right? They learn from reading your code. Make it good code, and your organization as a whole will improve. – Charles Duffy May 01 '19 at 00:17
  • @CharlesDuffy I've tried lots of approaches. Building an array sounds good. I have been unable to make that work so far. Can you show me some code that is approximately on point? There are lots of gotchas. – Mike Slinn May 01 '19 at 00:18

2 Answers2

3

The following follows best practices set out in BashFAQ #50:

# note that lower-case variable names are reserved for application use by POSIX
# see https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html
active_db="localhost"
dbs=( production localhost ) # using an array, not a string, means ${#dbs[@]} counts

# initialize an array with our explicit arguments
whiptail_args=(
  --title "Select Database"
  --radiolist "Select Database:"
  10 80 "${#dbs[@]}"  # note the use of ${#arrayname[@]} to get count of entries
)

i=0
for db in "${dbs[@]}"; do
  whiptail_args+=( "$((++i))" "$db" )
  if [[ $db = "$active_db" ]]; then    # only RHS needs quoting in [[ ]]
    whiptail_args+=( "on" )
  else
    whiptail_args+=( "off" )
  fi
done

# collect both stdout and exit status
# to grok the file descriptor switch, see https://stackoverflow.com/a/1970254/14122
whiptail_out=$(whiptail "${whiptail_args[@]}" 3>&1 1>&2 2>&3); whiptail_retval=$?

# display what we collected
declare -p whiptail_out whiptail_retval

While I don't have whiptail handy to test with, the exact invocation run by the above code is precisely identical to:

whiptail --title "Select Database" \
         --radiolist "Select Database:" 10 80 2 \
          1 production off \
          2 localhost on 

...as a string which, when evaled, runs the precise command can be generated with:

printf '%q ' whiptail "${whiptail_args[@]}"; echo
Charles Duffy
  • 280,126
  • 43
  • 390
  • 441
  • My `DBS` is a list resulting from a json query, your `dbs` seems to be something else. How to convert? – Mike Slinn May 01 '19 at 00:27
  • My `dbs` is a native bash array (as is the generated `whiptail_args`). If your JSON query is processed through `jq` and emits one item per line and you have bash 4.0 or newer, then `readarray -t dbs < <(jq -r ...)` should do the trick. – Charles Duffy May 01 '19 at 00:30
  • ...by contrast, if whatever tool you're parsing your JSON with emits all the items on a single line, you might need something more like `read -r -a dbs < <(...whatever... in.json)` – Charles Duffy May 01 '19 at 00:33
  • syntax error near unexpected token `<' command substitution: line 39: ` readarray -t dbs < < (cat "blah.json" | jq -r '.databases[]|.name' ) )"' – Mike Slinn May 01 '19 at 00:33
  • `<(` is a single token. No space between the `<` and `(` is permissible. See https://wiki.bash-hackers.org/syntax/expansion/proc_subst – Charles Duffy May 01 '19 at 00:34
  • Isn't this the easiest way to convert from a bash list to array? `dbs=( $DBS )` – Mike Slinn May 01 '19 at 00:34
  • @MikeSlinn, ...that's buggy. See [BashPitfalls #50](http://mywiki.wooledge.org/BashPitfalls#hosts.3D.28_.24.28aws_....29_.29). – Charles Duffy May 01 '19 at 00:35
  • Alright, Mr. Duffy, I learned once again that I do not know. Seems you do. How would you do it? – Mike Slinn May 01 '19 at 00:39
  • I do not get the desired results with this. Everything appears on one line. Might be because of the setup, which was discussed but not explicitly shown yet. – Mike Slinn May 01 '19 at 00:43
  • Mr. Duffy might do it like this: `readarray -t dbs <<< "$DBS"`. The danger of `dbs=($DBS)` is that it triggers glob expansion. Try using `DBS="*"` and you'll see the danger: `dbs=($DBS)` becomes `dbs=(*)` and the `*` gets expanded to the names of the files in the current directory, rather than being a literal asterisk. Oops! – John Kugelman May 01 '19 at 00:43
  • @JohnKugelman nothing, the console locks up on the previous statement as shown in the gist – Mike Slinn May 01 '19 at 01:01
  • Please add the xtrace logs showing which line execution hangs on to the gist. (I'd maybe run `set -x` all the way at the top, and `PS4=':$LINENO+'` to add the line number to the logs). – Charles Duffy May 01 '19 at 01:02
  • BTW, as an aside, `cat foo.txt | bar` is pretty much always better replaced with `bar – Charles Duffy May 01 '19 at 01:04
  • Mr. Duffy, I thank you, that suggestion works just fine – Mike Slinn May 01 '19 at 01:06
  • 1
    Ah yes. Whiptail for some strange reason prints its GUI output to stdout and the result to stderr (e.g. `2` if you pick radio button #2). To capture its output you don't use `$(...)`, you redirect stderr. A bizarre choice, IMO. See [this question for a workaround](https://stackoverflow.com/questions/1970180/whiptail-how-to-redirect-output-to-environment-variable). – John Kugelman May 01 '19 at 01:07
  • Beautiful (well, very annoying that it's necessary when well-behaved programs prompt either on stderr or direct to the TTY, but good to have a solution at hand); adopting that into the answer... – Charles Duffy May 01 '19 at 01:10
  • I updated the code and show output at https://gist.github.com/mslinn/0765e3eaaf90ce03f751384933eefa44 – Mike Slinn May 01 '19 at 01:10
  • @MikeSlinn, be sure you pick up the edit with the file descriptor switcheroo. – Charles Duffy May 01 '19 at 01:12
  • Mr. Duffy, that indeed does the job. No lockup. I see a dialog box. AND I get the user value and return code! Nice work. Thanks to @JohnKugelman also :) – Mike Slinn May 01 '19 at 01:20
0

Use arrays. They'll make this ten times easier. Let's start out small and work our way up. Here's $DBS and $DB_COUNT in array form:

DBS=(production localhost)
DB_COUNT=${#DBS[@]}

The advantage here is that $DBS actually has two entries, so we can count the number of entries with ${#DBS[@]} without having to shell out to external commands like wc.

So now let's tackle $DB_LIST. You're trying to add a couple options each loop iteration. Let's convert that to array syntax, using array+=(foo bar baz) to append items.

DB_LIST=()
I=1
for DB in "${DBS[@]}"; do
  if [ "$DB" == "$ACTIVE_DB" ]; then SELECTED=on; else SELECTED=off; fi
  DB_LIST+=("$I" "$DB" "$SELECTED")
  I=$(( I + 1 ))
done

Backslashes and newlines are interpreted by the shell, not whiptail. There's no need to throw them into the array so I got rid of the whole $SLASH variable. Newlines don't matter, so goodbye echo "".

Finally, let's run whiptail. There's no need for all the crazy quoting and eval-ing any more. We can just run it directly and expand the array we built at the right spot.

whiptail --title "Select Database" --radiolist "Select Database:" 10 80 "$DB_COUNT" "${DB_LIST[@]}"

There's still some more cleanup that could be done, but I think that's enough for one day.

John Kugelman
  • 349,597
  • 67
  • 533
  • 578
  • This code is interesting and instructive, but it does not quite work. – Mike Slinn May 01 '19 at 00:24
  • See https://ideone.com/Bhq4bV showing the whiptail command line this code generates/runs. Pointing out *how* it doesn't work would be useful; it looks correct to me at a glance. – Charles Duffy May 01 '19 at 00:29
  • @CharlesDuffy Thanks for the live code example. You can see that the result is all on one line, which is the problem I came here with. – Mike Slinn May 01 '19 at 00:48
  • 1
    As we explained, it all being on one line is not a problem. It makes no difference whether the command is on one or several lines. Whiptail doesn't see the newlines. I copy and pasted the ideone command into my Linux machine and it worked just fine: `whiptail --title Select\ Database --radiolist Select\ Database: 10 80 2 1 production off 2 localhost off`. – John Kugelman May 01 '19 at 00:52
  • @MikeSlinn, `whiptail` has no way of *knowing* how many lines its invocation was split over. Literally, at the OS level, that information never reaches it. Look at the man page for the `execve` syscall, which is how UNIX programs start each other; you'll see programs are just handed an array of C strings (with all the shell quotes and other syntax removed) as their argument list. Whether two subsequent elements of that array were on the same line, on different lines, etc. is invisible. – Charles Duffy May 01 '19 at 00:53
  • @MikeSlinn, ...if you want to prove this to yourself, write two scripts that differ only in whether they split a command onto two lines with a backslash or other valid separator, and run `strace -f -e execve bash ./your-script`, and compare the logs of the exact arguments passed to the OS; you'll see they're completely identical. – Charles Duffy May 01 '19 at 00:57