#!/bin/bash shellfu import pretty # # Root of the cache. # # All cache objects are created or looked up under this directory. The path # is validated against common dangerous scenarios; see $CACHED__ROOT_VALID. # CACHED__ROOT=${CACHED__ROOT:-} # # Consider $CACHED__ROOT always valid # # To minimize chance of damage, value of $CACHED__ROOT is validated using # heuristic based on common caching paths such as /tmp, /var/cache or # $HOME/.cache. # # However, if you insist on using non-standard caching location, set this # to 'always' to turn off this validation. # # Note that this has no effect on cases when $CACHED__ROOT is empty, # unset or exactly '/'; in these cases validation will always fail. # CACHED__ROOT_VALID=${CACHED__ROOT_VALID:-} cached() { # # Get cached or new output of command $@ # # Usage: # # CACHED__ROOT=$HOME/.cache/myapp # cached [-m] [-w] [-a ATTR] [-e ES_EXPR] CMD [ARG] # # Look up CMD with any ARGs in local cache and return result on hit. # In case of miss, run command and if exit status is zero (can be changed # by -e argument; see below), create the cache and return it. # # Cache objects are identified by computing a MD5 hash from combination # of several attributes. By default, only CMD and ARGs are included; # that is, same CMD+ARGs combination may match, no matter what is current # working directory. Optionally, you can specify -w if current directory # should be included in the identifier. (This is useful for commands like # 'ls'.) You can also add arbitrary string by providing ATTR parameter. # # For example, following set of commands would either hit or miss cache: # # CACHED__ROOT=$(mktemp -d) # cached ls /etc # miss (first run) # cached ls /etc # hit # cached -w ls /etc # miss (first run with $PWD consideration) # pushd /tmp # cached -w ls /etc # miss (different $PWD) # pushd /tmp # cached -w ls /etc # hit (back to previous $PWD) # cached -a foo ls /etc # miss (first run with 'foo') # cached -a bar ls /etc # miss (first run with 'bar') # cached -a foo ls /etc # hit (second run with 'foo') # # All cache objects are queried or created under directory specified by # global variable $CACHED__ROOT, which must be specified beforehand. # # Exit status has to match ES_EXPR in order to be matched. ES_EXPR has # form of comma-separated list of exit statuses or simple exit status # ranges. Valid ranges are: `N` which matches exactly `N` or `M-N`, # which matches any status from `M` to `N` inclusively. # # For example, following commands do or do not create cache (assuming # no cache hit): # # cached false # no: by default ES_EXPR is only 0 # cached -e 0,1 false # yes: 1 matches `0,1` # cached -e 0-3,9 exit_4 # no: 4 does not match `0-3,9` # cached -e 0-3,9 exit_3 # yes: 3 matches `0-3,9` # # NOTE: Caching of commands that process standard input is not supported. # (I.e. cached() will close standard input immediately.) # local Cache # local cache root local Workdir="$PWD" # current workdir local Command # command to run local es=2 # exit status of this function local Miss=false # force cache miss? local ObjPath # cache object path local MatchWD=false # does workdir matter? local Attr # custom attribute local EsExpr=0 # allowed exit status while true; do case $1 in --) shift; break ;; -a) Attr=$2; shift 2 || return 2 ;; -e) EsExpr=$2; shift 2 || return 2 ;; -w) MatchWD=true; shift ;; -m) Miss=true; shift ;; -*) warn "bad argument: $1"; return 2 ;; *) break ;; esac done __cached__validroot || { warn "cache root (\$CACHED__ROOT) is invalid: '$CACHED__ROOT'" return 2 } Cache=$CACHED__ROOT Command=$(printf '%q ' "$@") Command=${Command% } bash -n <<<"$Command" || { warn "command is not a valid Bash command: $Command" return 2 } ObjPath=$(__cached__objpath) debug -v Command ObjPath Attr Miss MatchWD exec 0<&- if $Miss; then debug FORCED_MISS __cached__run; es=$? elif __cached__hit; then debug HIT else debug MISS __cached__run; es=$? fi __cached__pull return $es } cached__kill() { # # Kill whole cache # __cached__validroot || { warn "cache root (\$CACHED__ROOT) is invalid: '$CACHED__ROOT'" return 2 } rm -rf "$CACHED__ROOT" } cached__prune() { # # Remove items older than age $1 # # Age must be in format: # # N[d] # # where N is an integer meaning age in minutes, unless suffix 'd' is # added, in which case N means age in days (ie. N * 24 hours). # # Examples: # # cached__prune 15 # remove items older than 15 minutes # cached__prune 5d # remove items older than 24*5 hours # local age=$1 local item local scancmd __cached__validroot || { warn "cache root (\$CACHED__ROOT) is invalid: '$CACHED__ROOT'" return 2 } scancmd="find $CACHED__ROOT/cached -mindepth 1 -maxdepth 1" case $age in *d) scancmd+=" -mtime +${age%d}" ;; *) scancmd+=" -mmin +$age" ;; esac for item in $(eval "$scancmd"); do rm -r "$item" done } __cached__describe() { # # Create command call description # echo "Command=$Command" $MatchWD && echo "Workdir=$(readlink -m "$Workdir")" test -n "$Attr" && echo "Attr=$Attr" } __cached__es_match() { # # True if exit status $1 matches expression $EsExpr # local es=$1 local part for part in ${EsExpr//,/ }; do test -n "$part" || continue #FIXME: a rather funny implementation (works, though...) eval "echo {${part/-/..}}" | grep -qwF "$es" && return 0 done return 1 } __cached__hit() { # # True if $Command has cache hit # $Miss && return 1 test -d "$ObjPath" } __cached__objid() { # # Describe command $Command called from directory $Workdir # __cached__describe | md5sum | cut -d\ -f1 } __cached__objpath() { # # Print cache object path # printf %s "$Cache/cached/$(__cached__objid)" } __cached__pull() { # # Pull result from cache object # cat "$ObjPath/out" cat "$ObjPath/err" >&2 return "$(<"$ObjPath/es")" } __cached__run() { # # Run command, creating cache object if exit status matches $EsExpr # local es # command exit status rm -rf "$ObjPath" mkdir -p "$ObjPath" __cached__describe >"$ObjPath/desc" eval "$Command" \ >"$ObjPath/out.tmp"\ 2>"$ObjPath/err.tmp"; es=$? echo $es>"$ObjPath/es.tmp" if __cached__es_match $es; then mv "$ObjPath/out.tmp" "$ObjPath/out" mv "$ObjPath/err.tmp" "$ObjPath/err" mv "$ObjPath/es.tmp" "$ObjPath/es" else warn "bad exit status; skipping cache creation: $es does not match $EsExpr" cat "$ObjPath/out.tmp" cat "$ObjPath/err.tmp" >&2 rm "$ObjPath/out.tmp" rm "$ObjPath/err.tmp" rm "$ObjPath/es.tmp" fi return $es } __cached__validroot() { # # True if $CACHED__ROOT is valid # test -n "$CACHED__ROOT" || return 1 test "$CACHED__ROOT" == / && return 1 test "$CACHED__ROOT_VALID" == 'always' && return 0 case $CACHED__ROOT in /var/cache/[[:word:].-]*) return 0 ;; /tmp/[[:word:].-]*) return 0 ;; /var/tmp/[[:word:].-]*) return 0 ;; $HOME/.cache/[[:word:].-]*) return 0 ;; esac return 1 } __cached__validcmd() { # # True if $Command is a valid Bash command # bash -n <<<"$Command" } #shellfu module-version=__MKIT_PROJ_VERSION__