0

Without installing anything on a barebones bash, is there a simple and clear way to:

  • test a value
  • print out a message if it's not what you want
  • still fail (without exiting the window/shell)

Currently, I've got:

[[ -n "$some_val" && -n "$other_val"]] || {echo "Unable to retrieve xxx from yyy"; false}

that's kind of a wtf to read, but then this is more verbose than I'd like for such a simple assertion:

if test -n "$some_val" && test -n "$other_val"; then
  echo "Unable to retrieve xxx from yyy"
  false
fi

What I'd really love would be to have something like:

test -n "$some_val" -m "some_val missing" && test -n "$other_val" "other_val missing"

or:

[[ -n "$some_val" && -n "$other_val" ]] || fail "Unable to retrieve xxx from yyy"
Azeem
  • 11,148
  • 4
  • 27
  • 40

4 Answers4

6
die() { rc=$?; (( $# )) && printf '%s\n' "$*" >&2; exit $(( rc == 0 ? 1 : rc )); }

do_something || die "explanation of how it failed"

Let's break this down:

  • Capturing $? at the top of the function lets us get the exit status that caused the function to be invoked.
  • Checking $# lets us log an error message only if there actually was an error to log.
  • If we do log a message, we send it to stderr so our message doesn't get mixed in with output (and potentially directed to a file or pipeline where the user will never see it).
  • $(( rc == 0 ? 1 : rc )) causes us to exit with status 1 if die was called when $? didn't reflect a failure, or the prior exit status otherwise.

If you don't want to exit the shell interpreter but just want to pass the exit status along, change the exit to return.

Charles Duffy
  • 280,126
  • 43
  • 390
  • 441
5

The function you want is trivial to define:

fail () {
    printf '%s\n' "$1" >&2
    return "${2:-1}"
}

test -n "$some_val" || fail "Value is empty"

(The definition is overly general, so that you can provide an explicit exit status for the function if 1 isn't desired, for example, complicated_test || fail "Failed, preserving exit status" $?. The $? will contain the exit status of the command that allowed the second half of the || list to execute.)

chepner
  • 497,756
  • 71
  • 530
  • 681
  • Missing a `>&2` on the `printf`, no? – Charles Duffy Jan 26 '23 at 16:57
  • Eh, probably. OP wasn't concerned about the distinction, though they should be. – chepner Jan 26 '23 at 16:58
  • Well, I've definitely learned a few things from asking this question, so I'm glad I did. That said, defining a function every time I want to use this (e.g., every GH Action step) seems like it adds more faff than it's worth — I was hoping there was some builtin that I was missing. There was, actually (`false`, which eliminates the `test -n ""` that I was doing), but it doesn't get me as far as I would like. – Jun-Dai Bates-Kobashigawa Jan 26 '23 at 17:26
1

Bash Parameter Expansion's ${foo:?msg} already does this, sort of...

${parameter:?word}

If parameter is null or unset, the expansion of word (or a message to that effect if word is not present) is written to the standard error and the shell, if it is not interactive, exits. Otherwise, the value of parameter is substituted.

That exits can throw some odd behavior, though, so be aware.

It's easy to test these with : (which is just a synonym for true, which returns a success, but accepts arguments it ignores, letting the interpreter parse them and very possibly fail... but a fail exits and will never even bother to execute :, aborting the entire pipeline, so you really can't intuitively test its results.

Observe:

$: : ${some_val:?unset} ${other_val:?oopsie whoops...} && echo vars ok
bash: some_val: unset

$: some_val=1
$: : ${some_val:?unset} ${other_val:?oopsie whoops...} && echo vars ok
bash: other_val: oopsie whoops...

$: other_val=2
$: : ${some_val:?unset} ${other_val:?oopsie whoops...} && echo vars ok
vars ok

You'd think you could use || to catch the error, but it doesn't work, because a fail exits the pipeline entirely.

$: : ${some_val:?unset} ${other_val:?oopsie whoops...} || echo not executed
bash: other_val: oopsie whoops...

The echo is in fact not executed. To get it to fire, put the tests in a subshell -

$: ( : ${some_val:?unset} ${other_val:?oopsie whoops...} ) || echo executed
bash: other_val: oopsie whoops...
executed

The || is now testing the exit code from the subshell...
But at this point the construct is really no simpler than an if structure, and is considerably harder to read and maintain, especially for those who come after you, so... Have you really gained anything? If you put it in a script, the script exits on the fail.

$: cat tst
#! /bin/bash
: ${some_val:?unset} && : ${other_val:?oopsie whoops...}
date

$: ./tst
./tst: line 2: some_val: unset

$: some_val=1 ./tst
./tst: line 2: other_val: oopsie whoops...

$: some_val=1 other_val=2 ./tst
Thu Jan 26 13:10:47 CST 2023

For simple scripts, this may be fine, but consider carefully.

It does make it reasonably easy to implement what you wanted as a function and customize behavior, though -

$: cat tst
#! /bin/bash
chk() { local -n v="$1"; local msg="${2:-unset}"; ( : ${v:?$1: $msg} ); }
chk some_val;                    echo chk returned $?;
chk other_val "oopsie whoops!!"; echo chk returned $?;
date

$: ./tst
./tst: line 2: v: some_val: unset
chk returned 1
./tst: line 2: v: other_val: oopsie whoops!!
chk returned 1
Thu Jan 26 13:28:29 CST 2023

$: some_val=1 ./tst
chk returned 0
./tst: line 2: v: other_val: oopsie whoops!!
chk returned 1
Thu Jan 26 13:29:55 CST 2023

$: other_val=1 ./tst
./tst: line 2: v: some_val: unset
chk returned 1
chk returned 0
Thu Jan 26 13:30:05 CST 2023

$: some_val=1 other_val=2 ./tst
chk returned 0
chk returned 0
Thu Jan 26 13:30:16 CST 2023
Paul Hodges
  • 13,382
  • 1
  • 17
  • 36
  • This does require a fairly recent version of `bash`... – Paul Hodges Jan 26 '23 at 19:38
  • `${var:?NoValueFor_VarErrMsg}` was in the original Bourne Shell. So I think it's always(?) been in bash. Glad you included this as a solution, I use it all the time. Your functionalized version is very nice! Goo luck to all. – shellter Jan 27 '23 at 21:36
  • I mostly meant namerefs. Those are more recent. :) – Paul Hodges Jan 27 '23 at 23:30
  • Ah yes, we see what we are familar with !-; . Yes, namerefs are new. Not that it mattes much anymore, `ksh` had namerefs since '93. sniff, RIP ksh93. (yes, I know its available still) . ALSO love your use of prepended variables `some_val=1 other_val=2 ./tst`. Another favorite ! Cheers – shellter Jan 27 '23 at 23:34
1

An alternative is to write a verify function which wraps around the commands you are trying to run. Benefit is that it can output all the relevant details about the failed command.

verify_task() {

    local name="$1"
    local command=("${@:2}")
    "${command[@]}"
    local return_code=$?

    if (( $return_code )); then
        {
            echo '[ERROR]'
            [ -n "$name" ] && echo "task: '$name'"
            printf "command: "; printf "'%s' " "${command[@]}"; echo
            echo "return_code: $return_code"
        } >&2
        exit $return_code
    fi
}

some_val=123
other_val=

verify_task "check some_val exists" test -n "$some_val"
verify_task "check other_val exists" test -n "$other_val"
# [ERROR]
# task: 'check other_val exists'
# command: 'test' '-n' ''
# return_code: 1

With piped commands, you'll still have to exit since verify_task will only exit the subshell.

echo 'hello error' | verify_task "find world" grep world || exit
# [ERROR]
# task: 'find world'
# command: 'grep' 'world'
# return_code: 1
Bromate
  • 430
  • 3
  • 5