9

I have a setup script for a Vagrant box where I used to measure single steps with time. Now I would like to conditionally enable or disable the time measurements.

For example, previously a line would look like:

time (apt-get update > /tmp/last.log 2>&1)

Now I thought I could simply do something like this:

MEASURE_TIME=true
[[ $MEASURE_TIME = true ]] && TIME="time --format=%e" || TIME=""

$TIME (apt-get update > /tmp/last.log 2>&1)

But this won't work:

syntax error near unexpected token `apt-get'
`$TIME (apt-get update > /tmp/last.log 2>&1)'

What's the problem here?

2 Answers2

13

To be able to time a subshell, you need the time keyword, not command.

The time keyword, part of the language, is only recognised as such when entered literally and as the first word of a command (and in the case of ksh93, then the next token doesn't start with a -). Even entering "time" won't work let alone $TIME (and would be taken as a call to the time command instead).

You could use aliases here which are expanded before another round of parsing is performed (so would let the shell recognise that time keyword):

shopt -s expand_aliases
alias time_or_not=
TIMEFORMAT=%E

MEASURE_TIME=true
[[ $MEASURE_TIME = true ]] && alias time_or_not=time

time_or_not (apt-get update > /tmp/last.log 2>&1)

The time keyword doesn't take options (except for -p in bash), but the format can be set with the TIMEFORMAT variable in bash. (the shopt part is also bash-specific, other shells generally don't need that).

3

While an alias is one way to do it, this can be done with eval as well - it's just that you don't so much want to eval the command execution as you want to eval the command declaration.

I like aliases - I use 'em all the time, but I like functions better - especially their ability to handle parameters and that they needn't necessarily be expanded in command position as is required for aliases.

So I thought maybe you'd want to try this, too:

_time() if   set -- "${IFS+IFS=\$2;}" "$IFS" "$@" && IFS='
';      then set -- "$1" "$2" "$*"; unset IFS
             eval "$1 $TIME ${3#"$1"?"$2"?}"
        fi

The $IFS bit is mainly about $*. It's important that the ( subshell bit ) is also the result of a shell expansion and so in order to expand the arguments into a parsable string I use "$*" (don't eval "$@", by the way, unless you're certain all of the args can be joined on spaces). The split delimiter between args in "$*" is the first byte in $IFS, and so it could be dangerous to proceed without making certain its value. So the function saves $IFS, sets it to a \newline long enough to set ... "$*" into "$3", unsets it, then resets its value if it previously had one.

Here's a little demo:

TIME='set -x; time'
_time \( 'echo "$(echo any number of subshells)"' \
         'command -V time'                        \
         'hash time'                              \
      \) 'set +x'

You see I put two commands in the value of $TIME there - any number is fine - even none - but be sure it is escaped and quoted properly - and the same goes for the arguments to _time(). They will all be concatenated into a single command string when they are executed - but each arg gets its own \newline and so they can be spread out relatively easily. Or else you can lump them all in one, if you like, and separate them on \newlines or semi-colons or what-have-you. Just be sure that a single argument represents a command you'd feel comfortable putting on its own line in a script when you call it. \(, for example is fine, so long as it is eventually followed with \). Basically the normal stuff.

When eval gets the above snippet fed it it looks like:

+ eval 'IFS=$2;set -x; time (
echo "$(echo any number of subshells)"
command -V time
hash time
)
set +x'

And, its results look like...

OUTPUT

+++ echo any number of subshells
++ echo 'any number of subshells'
any number of subshells
++ command -V time
time is a shell keyword
++ hash time
bash: hash: time: not found

real 0m0.003s user 0m0.000s sys 0m0.000s ++ set +x

The hash error indicates that I don't have a /usr/bin/time installed (because I don't) and command let's us know which time is running. The trailing set +x is another command executed after time (which is possible) - it is important to be careful with input commands when evaling anything.

mikeserv
  • 58,310
  • Any reason for not making it _time() { eval "$TIME $@"; }? Using a function has the drawback of introducing a different scope for variables (not an issue for subshells) – Stéphane Chazelas Dec 18 '14 at 10:28
  • @StéphaneChazelas - because of the quotes in the "$@" expansion. I like a single arg to eval - it get's scary otherwise. Ummm.... how do you mean about the different scope though? I thought that was just function functions... – mikeserv Dec 18 '14 at 10:46
  • eval joins its args before executing which is what you are trying to do in a very convoluted way. I meant that _time 'local var=1; blah' would make that var local to that _time, or that _time 'echo "$#"' would print the $# of that _time function, not that of the caller. – Stéphane Chazelas Dec 18 '14 at 13:44
  • @StéphaneChazelas - I got two tab completes for you here. Right - eval concats its args on spaces - which is, as you say, exactly what I do here - though it was not inital intent. I'm going to fix that. evaling "$*" as opposed to "$@" is a habit I picked up after many run-in's with command not found when args were joined at the wrong place. In any case, though the args are ultimately executed in the function, it is simple enough to expand them at invocation, I think. It is what I would do with "$#" anyway. – mikeserv Dec 18 '14 at 14:12
  • +1 for the nice twisted piece of hackery though... – Stéphane Chazelas Dec 18 '14 at 14:20
  • @StéphaneChazelas - gracias, though I'm not sure which sc I address. This comment thread is weird. – mikeserv Dec 18 '14 at 14:51