I'm doing a lot of Python development lately for various (small-)data analysis pipelines at work. I've been wrestling with how to robustly and ~automatically version the code at a fine-grained level, so as to provide strong guarantees of reproducibility of a result generated from any particular version of the code, at any particular point in my development process.
I've settled on a CalVer approach. Given that I often want to have multiple versions of the code tagged within a single day, I'm using a ~nonstandard $TIMESTAMP
format of YYYY.MM.DD.hhmm
. (hhmmss
seemed like it would be overkill.)
In any event, I want two things to happen every time I commit code to one of these data analysis repos:
- Wherever relevant in the package (usually just in the main
__init__.py
),__version__
should be updated to$TIMESTAMP
. - Once the code is committed, a tag named
$TIMESTAMP
should be applied to the new commit
Ancillary goals are the usual: easy to configure, minimal likelihood of breaking all the things, and minimal additional cleanup effort in common non-happy-path scenarios.
The following is a bash script I've put together for the purpose:
#! /bin/bash
export TIMESTAMP="$( date '+%Y.%m.%d.%H%M' )"
export VERPATH='.verpath'
if [ -z $VERPATH ]
then
# Complain and exit
echo "ERROR: Path to files with versions to update must be provided in {repo root}/.verpath"
echo " "
exit 1
fi
# $VERPATH must contain the paths to the files to be updated with
# the timestamped version, one per line
while read VERFILE
do
# Cosmetic
echo ""
if [ -e "$VERFILE" ]
then
# File to be updated with version exists; update and add to commit.
# Tempfile with old file stored in case of commit cancellation.
echo "Updating $VERFILE"
cp "$VERFILE" "$VERFILE.tmp"
sed -i "s/^__version__ = .*$/__version__ = '$TIMESTAMP'/" $VERFILE
git add "$VERFILE"
else
echo "$VERFILE not found!"
fi
done < $VERPATH
# Cosmetic
echo ""
# So user can see what was updated
sleep 2s
# Actually do the commit, passing through any parameters
git commit $@
# If the commit succeeded, tag HEAD with $TIMESTAMP and delete temp file(s).
# If the commit failed, restore the prior state of the $VERFILEs.
if [ "$?" -eq "0" ]
then
git tag -f "$TIMESTAMP"
while read VERFILE
do
rm -f "$VERFILE.tmp"
done < $VERPATH
else
while read VERFILE
do
if [ -e "$VERFILE.tmp" ]
then
git reset HEAD "$VERFILE" > /dev/null 2>&1
rm "$VERFILE"
mv "$VERFILE.tmp" "$VERFILE"
fi
done < $VERPATH
fi
The contents of .verpath
in my test repo are:
pkg/__init__.py
pkg/__dupe__.py
pkg/nofile.py
Both pkg/__init__.py
and pkg/__dupe__.py
exist; pkg/nofile.py
does not.
I have *.tmp
in my .gitignore
so that the $VERFILE.tmp
don't show up as untracked files when drafting the commit message.
It works like I want it to... the happy path works great, and it handles aborted commits and nonexistent .verpath
files gracefully.
I'm no bash expert, though, so I'm partly concerned about subtle misbehaviors I haven't thought of. Also, I'm not super thrilled about the use of in-folder temporary files, and per here and here the while read VERFILE ... done < $VERPATH
has the potential to be fragile if I don't set it up correctly.