From 8546b114291a9cbee30c2d2fc26f023da8c97b52 Mon Sep 17 00:00:00 2001 From: jakelodwick <25925+jakelodwick@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:39:30 -0600 Subject: [PATCH 1/3] Cache hook state to skip redundant pyenv sh-activate calls Mtime-based caching: stat the directory tree once per prompt (~2ms) and skip full activation when nothing has changed. - Compare PYENV_VERSION, VIRTUAL_ENV, PWD; shortcut when PYENV_VERSION matches - Walk PWD to / for .python-version (stop at first; include broken symlinks) - Path list built once on miss, stored as array (spaces in paths) - Global version file skipped when local version active - version-name hooks checked at init; if present, caching disabled - Covers bash, zsh, and fish --- bin/pyenv-virtualenv-init | 110 +++++++++++++++++++++++++++++++++++++- test/init.bats | 93 ++++++++++++++++++++++++++++++++ test/test_helper.bash | 8 +++ 3 files changed, 209 insertions(+), 2 deletions(-) diff --git a/bin/pyenv-virtualenv-init b/bin/pyenv-virtualenv-init index e12769a8..118ab205 100755 --- a/bin/pyenv-virtualenv-init +++ b/bin/pyenv-virtualenv-init @@ -9,6 +9,23 @@ set -e [ -n "$PYENV_DEBUG" ] && set -x +# Detect stat format for mtime: GNU uses -c %Y, BSD uses -f %m +# -L follows symlinks: a symlinked .python-version reflects target changes +if stat -L -c %Y / >/dev/null 2>&1; then + _stat_fmt="-L -c %Y" +else + _stat_fmt="-L -f %m" +fi + +# Check for version-name hooks at init time. Hooks can alter version +# resolution in ways the mtime cache cannot track. If present, the hook +# falls back to upstream behavior (no caching). Restart shell after +# installing or removing pyenv plugins. +_has_version_hooks="" +if [ -n "$(pyenv hooks version-name 2>/dev/null)" ]; then + _has_version_hooks=1 +fi + resolve_link() { $(type -p greadlink readlink | head -1) "$1" } @@ -103,7 +120,8 @@ esac case "$shell" in fish ) - cat </dev/null)" = "\$_PYENV_VH_MTIMES" + return \$ret + end + end + if [ -n "\$VIRTUAL_ENV" ] + pyenv activate --quiet; or pyenv deactivate --quiet; or true + else + pyenv activate --quiet; or true + end + set -g _PYENV_VH_PWD "\$PWD" + set -g _PYENV_VH_VERSION "\$PYENV_VERSION" + set -g _PYENV_VH_VENV "\$VIRTUAL_ENV" + set -l d "\$PWD" + set -l _pvh_found_local 0 + set -g _PYENV_VH_PATHS + while true + if test -e "\$d/.python-version"; or test -L "\$d/.python-version" + set -g _PYENV_VH_PATHS \$_PYENV_VH_PATHS "\$d/.python-version" + set _pvh_found_local 1 + break + end + set -g _PYENV_VH_PATHS \$_PYENV_VH_PATHS "\$d" + test "\$d" = "/"; and break + set d (string replace -r '/[^/]*\$' '' -- "\$d") + test -z "\$d"; and set d "/" + end + if test "\$_pvh_found_local" = "0" + set -g _PYENV_VH_PATHS \$_PYENV_VH_PATHS "\$PYENV_ROOT/version" + end + set -g _PYENV_VH_MTIMES (stat ${_stat_fmt} \$_PYENV_VH_PATHS 2>/dev/null) + return \$ret +end +EOS + fi ;; ksh ) cat </dev/null)" = "\${_PYENV_VH_MTIMES-}" ]; then + return \$ret + fi + fi + if [ -n "\${VIRTUAL_ENV-}" ]; then + eval "\$(pyenv sh-activate --quiet || pyenv sh-deactivate --quiet || true)" || true + else + eval "\$(pyenv sh-activate --quiet || true)" || true + fi + _PYENV_VH_PWD="\${PWD}" + _PYENV_VH_VERSION="\${PYENV_VERSION-}" + _PYENV_VH_VENV="\${VIRTUAL_ENV-}" + local _pvh_d="\${PWD}" _pvh_found_local=0 + _PYENV_VH_PATHS=() + while :; do + if [ -e "\${_pvh_d}/.python-version" ] || [ -L "\${_pvh_d}/.python-version" ]; then + _PYENV_VH_PATHS+=("\${_pvh_d}/.python-version") + _pvh_found_local=1 + break + fi + _PYENV_VH_PATHS+=("\${_pvh_d}") + [ "\${_pvh_d}" = "/" ] && break + _pvh_d="\${_pvh_d%/*}" + [ -z "\${_pvh_d}" ] && _pvh_d="/" + done + if [ "\${_pvh_found_local}" = "0" ]; then + _PYENV_VH_PATHS+=("\${PYENV_ROOT}/version") + fi + _PYENV_VH_MTIMES="\$(stat ${_stat_fmt} "\${_PYENV_VH_PATHS[@]}" 2>/dev/null)" + return \$ret +}; +EOS + fi case "$shell" in bash ) diff --git a/test/init.bats b/test/init.bats index 85f2b423..ff4c041a 100644 --- a/test/init.bats +++ b/test/init.bats @@ -54,11 +54,42 @@ export PATH="${TMP}/pyenv/plugins/pyenv-virtualenv/shims:\${PATH}"; export PYENV_VIRTUALENV_INIT=1; _pyenv_virtualenv_hook() { local ret=\$? + # Cache: env vars checked once, path list and stat rebuilt on miss only + if [ "\${PYENV_VERSION-}" = "\${_PYENV_VH_VERSION-}" ] \\ + && [ "\${VIRTUAL_ENV-}" = "\${_PYENV_VH_VENV-}" ]; then + if [ -n "\${PYENV_VERSION-}" ]; then + return \$ret + fi + if [ "\${PWD}" = "\${_PYENV_VH_PWD-}" ] \\ + && [ "\$(stat ${_stat_fmt} "\${_PYENV_VH_PATHS[@]}" 2>/dev/null)" = "\${_PYENV_VH_MTIMES-}" ]; then + return \$ret + fi + fi if [ -n "\${VIRTUAL_ENV-}" ]; then eval "\$(pyenv sh-activate --quiet || pyenv sh-deactivate --quiet || true)" || true else eval "\$(pyenv sh-activate --quiet || true)" || true fi + _PYENV_VH_PWD="\${PWD}" + _PYENV_VH_VERSION="\${PYENV_VERSION-}" + _PYENV_VH_VENV="\${VIRTUAL_ENV-}" + local _pvh_d="\${PWD}" _pvh_found_local=0 + _PYENV_VH_PATHS=() + while :; do + if [ -e "\${_pvh_d}/.python-version" ] || [ -L "\${_pvh_d}/.python-version" ]; then + _PYENV_VH_PATHS+=("\${_pvh_d}/.python-version") + _pvh_found_local=1 + break + fi + _PYENV_VH_PATHS+=("\${_pvh_d}") + [ "\${_pvh_d}" = "/" ] && break + _pvh_d="\${_pvh_d%/*}" + [ -z "\${_pvh_d}" ] && _pvh_d="/" + done + if [ "\${_pvh_found_local}" = "0" ]; then + _PYENV_VH_PATHS+=("\${PYENV_ROOT}/version") + fi + _PYENV_VH_MTIMES="\$(stat ${_stat_fmt} "\${_PYENV_VH_PATHS[@]}" 2>/dev/null)" return \$ret }; if ! [[ "\${PROMPT_COMMAND-}" =~ _pyenv_virtualenv_hook ]]; then @@ -78,11 +109,42 @@ set -gx PATH '${TMP}/pyenv/plugins/pyenv-virtualenv/shims' \$PATH; set -gx PYENV_VIRTUALENV_INIT 1; function _pyenv_virtualenv_hook --on-event fish_prompt; set -l ret \$status + if test "\$PYENV_VERSION" = "\$_PYENV_VH_VERSION" \\ + -a "\$VIRTUAL_ENV" = "\$_PYENV_VH_VENV" + if test -n "\$PYENV_VERSION" + return \$ret + end + if test "\$PWD" = "\$_PYENV_VH_PWD" \\ + -a "(stat ${_stat_fmt} \$_PYENV_VH_PATHS 2>/dev/null)" = "\$_PYENV_VH_MTIMES" + return \$ret + end + end if [ -n "\$VIRTUAL_ENV" ] pyenv activate --quiet; or pyenv deactivate --quiet; or true else pyenv activate --quiet; or true end + set -g _PYENV_VH_PWD "\$PWD" + set -g _PYENV_VH_VERSION "\$PYENV_VERSION" + set -g _PYENV_VH_VENV "\$VIRTUAL_ENV" + set -l d "\$PWD" + set -l _pvh_found_local 0 + set -g _PYENV_VH_PATHS + while true + if test -e "\$d/.python-version"; or test -L "\$d/.python-version" + set -g _PYENV_VH_PATHS \$_PYENV_VH_PATHS "\$d/.python-version" + set _pvh_found_local 1 + break + end + set -g _PYENV_VH_PATHS \$_PYENV_VH_PATHS "\$d" + test "\$d" = "/"; and break + set d (string replace -r '/[^/]*\$' '' -- "\$d") + test -z "\$d"; and set d "/" + end + if test "\$_pvh_found_local" = "0" + set -g _PYENV_VH_PATHS \$_PYENV_VH_PATHS "\$PYENV_ROOT/version" + end + set -g _PYENV_VH_MTIMES (stat ${_stat_fmt} \$_PYENV_VH_PATHS 2>/dev/null) return \$ret end EOS @@ -97,11 +159,42 @@ export PATH="${TMP}/pyenv/plugins/pyenv-virtualenv/shims:\${PATH}"; export PYENV_VIRTUALENV_INIT=1; _pyenv_virtualenv_hook() { local ret=\$? + # Cache: env vars checked once, path list and stat rebuilt on miss only + if [ "\${PYENV_VERSION-}" = "\${_PYENV_VH_VERSION-}" ] \\ + && [ "\${VIRTUAL_ENV-}" = "\${_PYENV_VH_VENV-}" ]; then + if [ -n "\${PYENV_VERSION-}" ]; then + return \$ret + fi + if [ "\${PWD}" = "\${_PYENV_VH_PWD-}" ] \\ + && [ "\$(stat ${_stat_fmt} "\${_PYENV_VH_PATHS[@]}" 2>/dev/null)" = "\${_PYENV_VH_MTIMES-}" ]; then + return \$ret + fi + fi if [ -n "\${VIRTUAL_ENV-}" ]; then eval "\$(pyenv sh-activate --quiet || pyenv sh-deactivate --quiet || true)" || true else eval "\$(pyenv sh-activate --quiet || true)" || true fi + _PYENV_VH_PWD="\${PWD}" + _PYENV_VH_VERSION="\${PYENV_VERSION-}" + _PYENV_VH_VENV="\${VIRTUAL_ENV-}" + local _pvh_d="\${PWD}" _pvh_found_local=0 + _PYENV_VH_PATHS=() + while :; do + if [ -e "\${_pvh_d}/.python-version" ] || [ -L "\${_pvh_d}/.python-version" ]; then + _PYENV_VH_PATHS+=("\${_pvh_d}/.python-version") + _pvh_found_local=1 + break + fi + _PYENV_VH_PATHS+=("\${_pvh_d}") + [ "\${_pvh_d}" = "/" ] && break + _pvh_d="\${_pvh_d%/*}" + [ -z "\${_pvh_d}" ] && _pvh_d="/" + done + if [ "\${_pvh_found_local}" = "0" ]; then + _PYENV_VH_PATHS+=("\${PYENV_ROOT}/version") + fi + _PYENV_VH_MTIMES="\$(stat ${_stat_fmt} "\${_PYENV_VH_PATHS[@]}" 2>/dev/null)" return \$ret }; typeset -g -a precmd_functions diff --git a/test/test_helper.bash b/test/test_helper.bash index 3d179c9a..7d927d59 100644 --- a/test/test_helper.bash +++ b/test/test_helper.bash @@ -1,4 +1,12 @@ export TMP="$BATS_TEST_DIRNAME/tmp" + +# Detect stat format for mtime: GNU uses -c %Y, BSD uses -f %m +# Must match the detection in bin/pyenv-virtualenv-init +if stat -L -c %Y / >/dev/null 2>&1; then + _stat_fmt="-L -c %Y" +else + _stat_fmt="-L -f %m" +fi export PS4='+(${BASH_SOURCE}:${LINENO}): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }' PATH=/usr/bin:/usr/sbin:/bin:/sbin From 6d048007e87aa3caa0873088e1fc86e7db99ee91 Mon Sep 17 00:00:00 2001 From: Ivan Pozdeev Date: Wed, 25 Mar 2026 20:41:28 +0300 Subject: [PATCH 2/3] For broken .python-version symlinks, remember them but keep going --- bin/pyenv-virtualenv-init | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/bin/pyenv-virtualenv-init b/bin/pyenv-virtualenv-init index 118ab205..138fb48e 100755 --- a/bin/pyenv-virtualenv-init +++ b/bin/pyenv-virtualenv-init @@ -158,12 +158,15 @@ function _pyenv_virtualenv_hook --on-event fish_prompt; set -l _pvh_found_local 0 set -g _PYENV_VH_PATHS while true - if test -e "\$d/.python-version"; or test -L "\$d/.python-version" + if test -f "\$d/.python-version"; or test -L "\$d/.python-version" set -g _PYENV_VH_PATHS \$_PYENV_VH_PATHS "\$d/.python-version" - set _pvh_found_local 1 - break + if test -f "\$d/.python-version" + set _pvh_found_local 1 + break + end + else + set -g _PYENV_VH_PATHS \$_PYENV_VH_PATHS "\$d" end - set -g _PYENV_VH_PATHS \$_PYENV_VH_PATHS "\$d" test "\$d" = "/"; and break set d (string replace -r '/[^/]*\$' '' -- "\$d") test -z "\$d"; and set d "/" @@ -226,12 +229,15 @@ EOS local _pvh_d="\${PWD}" _pvh_found_local=0 _PYENV_VH_PATHS=() while :; do - if [ -e "\${_pvh_d}/.python-version" ] || [ -L "\${_pvh_d}/.python-version" ]; then + if [ -f "\${_pvh_d}/.python-version" ] || [ -L "\${_pvh_d}/.python-version" ]; then _PYENV_VH_PATHS+=("\${_pvh_d}/.python-version") - _pvh_found_local=1 - break + if [ -f "\${_pvh_d}/.python-version" ]; then + _pvh_found_local=1 + break + fi + else + _PYENV_VH_PATHS+=("\${_pvh_d}") fi - _PYENV_VH_PATHS+=("\${_pvh_d}") [ "\${_pvh_d}" = "/" ] && break _pvh_d="\${_pvh_d%/*}" [ -z "\${_pvh_d}" ] && _pvh_d="/" From 27500cd3df0b025698f6939eaf9b317fabfbd2ce Mon Sep 17 00:00:00 2001 From: Ivan Pozdeev Date: Wed, 25 Mar 2026 20:52:30 +0300 Subject: [PATCH 3/3] adjust tests --- test/init.bats | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/test/init.bats b/test/init.bats index ff4c041a..2aacc8fc 100644 --- a/test/init.bats +++ b/test/init.bats @@ -76,12 +76,15 @@ _pyenv_virtualenv_hook() { local _pvh_d="\${PWD}" _pvh_found_local=0 _PYENV_VH_PATHS=() while :; do - if [ -e "\${_pvh_d}/.python-version" ] || [ -L "\${_pvh_d}/.python-version" ]; then + if [ -f "\${_pvh_d}/.python-version" ] || [ -L "\${_pvh_d}/.python-version" ]; then _PYENV_VH_PATHS+=("\${_pvh_d}/.python-version") - _pvh_found_local=1 - break + if [ -f "\${_pvh_d}/.python-version" ]; then + _pvh_found_local=1 + break + fi + else + _PYENV_VH_PATHS+=("\${_pvh_d}") fi - _PYENV_VH_PATHS+=("\${_pvh_d}") [ "\${_pvh_d}" = "/" ] && break _pvh_d="\${_pvh_d%/*}" [ -z "\${_pvh_d}" ] && _pvh_d="/" @@ -131,12 +134,15 @@ function _pyenv_virtualenv_hook --on-event fish_prompt; set -l _pvh_found_local 0 set -g _PYENV_VH_PATHS while true - if test -e "\$d/.python-version"; or test -L "\$d/.python-version" + if test -f "\$d/.python-version"; or test -L "\$d/.python-version" set -g _PYENV_VH_PATHS \$_PYENV_VH_PATHS "\$d/.python-version" - set _pvh_found_local 1 - break + if test -f "\$d/.python-version" + set _pvh_found_local 1 + break + end + else + set -g _PYENV_VH_PATHS \$_PYENV_VH_PATHS "\$d" end - set -g _PYENV_VH_PATHS \$_PYENV_VH_PATHS "\$d" test "\$d" = "/"; and break set d (string replace -r '/[^/]*\$' '' -- "\$d") test -z "\$d"; and set d "/" @@ -181,12 +187,15 @@ _pyenv_virtualenv_hook() { local _pvh_d="\${PWD}" _pvh_found_local=0 _PYENV_VH_PATHS=() while :; do - if [ -e "\${_pvh_d}/.python-version" ] || [ -L "\${_pvh_d}/.python-version" ]; then + if [ -f "\${_pvh_d}/.python-version" ] || [ -L "\${_pvh_d}/.python-version" ]; then _PYENV_VH_PATHS+=("\${_pvh_d}/.python-version") - _pvh_found_local=1 - break + if [ -f "\${_pvh_d}/.python-version" ]; then + _pvh_found_local=1 + break + fi + else + _PYENV_VH_PATHS+=("\${_pvh_d}") fi - _PYENV_VH_PATHS+=("\${_pvh_d}") [ "\${_pvh_d}" = "/" ] && break _pvh_d="\${_pvh_d%/*}" [ -z "\${_pvh_d}" ] && _pvh_d="/"