Introduction
Recently, I've encountered the issue of needing to conditionally running single commands with direnv when setting up direnv
for python-poetry
.
I was surprised to not find the tools I needed in the direnv stdlib and would like to share my very simple solution.
To give an explanation of the issue I was trying to solve, let me introduce you to these tools first.
poetry and direnv?
-
poetry, is a tool for managing the dependencies and the virtual environments of a python project
-
direnv is a tool for automatically executing scripts and loading environment variables when entering a directory. To put things simply, whenever you enter a directory, it looks for a
.envrc
script in that directory and runs it. When navigating back out of the directory, it will automatically unload the exported variables of the script.
Together, they can be used to ergonomically enter your poetry-managed virtual environment whenever you enter your Python project directory, no manual commands required.
Official setup
The direnv wiki offers recommended setups for a wide range of different tools and languages. They recommend the following setup for poetry:
layout_poetry() {
PYPROJECT_TOML="${PYPROJECT_TOML:-pyproject.toml}"
if [[ ! -f "$PYPROJECT_TOML" ]]; then
log_status "No pyproject.toml found. Executing \`poetry init\` to create a \`$PYPROJECT_TOML\` first."
poetry init
fi
if [[ -d ".venv" ]]; then
VIRTUAL_ENV="$(pwd)/.venv"
else
VIRTUAL_ENV=$(poetry env info --path 2>/dev/null ; true)
fi
if [[ -z $VIRTUAL_ENV || ! -d $VIRTUAL_ENV ]]; then
log_status "No virtual environment exists. Executing \`poetry install\` to create one."
poetry install
VIRTUAL_ENV=$(poetry env info --path)
fi
PATH_add "$VIRTUAL_ENV/bin"
export POETRY_ACTIVE=1
export VIRTUAL_ENV
}
There is one major issue with this implementation: it won't automatically recreate the virtual environment when dependencies in pyproject.toml
have changed.
This change does not necessarily have to be caused by yourself, it might be applied by dependabot, a colleague or your CI on your git remote, and you likely won't
always be aware of every file change introduced by a git pull
.
In the worst case, failing to notice that a change has been made to the dependencies might cause you commit changes incomptabile with the version of the dependency already commited to your project's configuration.
The first step to resolve this would be running poetry install --sync
in your .envrc
to sync the environment with your pyproject.toml every single time it gets loaded by direnv.
Reloading your environment whenever pyproject.toml
can then achieved by using the watch_file function from the direnv stdlib.
watch_file pyproject.toml
poetry install --sync
However, this approach also has a downside - the poetry install --sync
command is invoked unnecessarily when your .envrc
is invoked without change of the pyproject.toml, e.g. when just leaving and re-entering the directory.
Here, just activating the virtual environment would be completely sufficient but instead it spams your shell and causes a short delay while the .envrc
is being executed when no change is actually necessary.
$ cd poetry-test/
direnv: loading ~/poetry-test/.envrc
Installing dependencies from lock file
# ...
# list of every dependency you're using
#
Installing the current project: poetry-test (0.1.0)
direnv: export +POETRY_ACTIVE +VIRTUAL_ENV ~PATH
$ _
Let's try to find an approach that only runs specific commands in the .envrc when actually necessary.
Solution
Our goal is to conditionally run some parts of the script. This isn't exactly rocket science, we'll resort to some simple shell scripting.
There are several different heuristics we can go by when to actually perform a command:
- the timestamp of the file - only run the command if the timestamp has been updated, i.e. the file has been written to
- compute a hash of the file contents - only run the command if the hash has changed
Dependending on your requirements, one of these might be more appropriate. I'm going with the timestamp approach.
run_if_file_changed () {
local cache_file=".direnv_cache/$1.timestamp"
local cached_timestamp="$([[ -r "$cache_file" ]] && cat "$cache_file")"
local last_changed_timestamp="$(date +%s -r "$1")"
if [[ "$cached_timestamp" == "" || "$cached_timestamp" != "$last_changed_timestamp" ]]; then
eval "$2"
echo "$last_changed_timestamp" > "$cache_file"
fi
}
run_if_file_changed pyproject.toml "poetry install --sync"
This solution is not very complex but the use case seems so common that I believe this could be provided by the direnv stdlib.