Skip to content

Cache hook state to skip redundant pyenv sh-activate calls#523

Open
jakelodwick wants to merge 2 commits intopyenv:masterfrom
jakelodwick:cache-prompt-hook
Open

Cache hook state to skip redundant pyenv sh-activate calls#523
jakelodwick wants to merge 2 commits intopyenv:masterfrom
jakelodwick:cache-prompt-hook

Conversation

@jakelodwick
Copy link

@jakelodwick jakelodwick commented Mar 14, 2026

Problem

_pyenv_virtualenv_hook calls pyenv sh-activate --quiet on every prompt, spawning ~10 subprocesses through the pyenv dispatcher regardless of whether anything has changed. On macOS this adds ~200ms of latency per keystroke+Enter (#259, #490, #338).

Approach

Make the hook detect "nothing changed" and short-circuit before forking, rather than skipping the check entirely (the approach that #456 took, which broke pyenv local).

The hook now caches five values using shell builtins (zero forks):

Cache key Detects
$PWD directory changes (cd)
$PYENV_VERSION pyenv shell changes
$PWD/.python-version content pyenv local changes
$PYENV_ROOT/version content pyenv global changes
$VIRTUAL_ENV manual venv activation/deactivation

When all five match their cached values, the hook returns immediately. When any value changes, the full pyenv sh-activate path runs and the cache is refreshed.

Limitation

.python-version files in parent directories are not tracked. pyenv walks up the directory tree to find .python-version; this cache only reads the file in $PWD. If a parent directory's file changes while the user is in a subdirectory, the next cd triggers a full recheck. Walking up directories from the hook would replicate pyenv's version resolution logic.

Measurement

Test plan

  • All 104 existing bats tests pass
  • Three exact-output tests updated (bash, fish, zsh)
  • Cache invalidation verified for all five state transitions

Acknowledgments

This approach builds on prior work:

Track five values between prompts ($PWD, $PYENV_VERSION,
.python-version content, $PYENV_ROOT/version content, $VIRTUAL_ENV)
using shell builtins. When all match, return immediately without
forking. Covers cd, pyenv shell/local/global, and manual
venv activate/deactivate.

Implemented for bash/zsh/ksh (shared POSIX path) and fish.
@jakelodwick jakelodwick marked this pull request as ready for review March 14, 2026 12:31
Copy link
Member

@native-api native-api left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good for a start!

The "limitations" you've outlined are exactly why we haven't done something like this yet! Their complexity has intimidated all the prospective contributors before you! And a stale cache would be a bug -- so we'd have to revert any fundamentally flawed solution in order to fix it.

I think we actually can check for on-disk changes with absolute minimum overhead -- by caching mtimes of the active .pyenv-version file, and of any directories without a .pyenv-version file leading up to it. Then all the disk activity we'll have to do at a prompt is up to a few stat calls.

  • As you can see, this makes it unnecessary to actually read the files each time
  • Moreover, if the current version is set by a higher-priority mechanism (shell -> local -> global) -- we don't have to check for changes in the lower-priority mechanisms at all!
  • stat is not a builtin. It is possible to do with the test -nt builtin -- but then we'd have to create a marker file (which will be per-shell-session) and somehow maintain it. We can create our own builtin if process spawns are really as big of a deal as you make it look...

Comment on lines +110 to +116
if test -f "\$PWD/.python-version"
read -z pvh_local < "\$PWD/.python-version" 2>/dev/null; or true
end
set -l pvh_global ""
if test -f "\$PYENV_ROOT/version"
read -z pvh_global < "\$PYENV_ROOT/version" 2>/dev/null; or true
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you probably guessed, this duplicates pyenv version logic.
If we leave it as a separate logic, we must somehow make sure that it remains in lockstep with the subcommand proper.

The most realistic way I see is to create a shared sourced file in Pyenv with a function that checks "if the active version has changed". It can very well read and write the cache as well. Then we add some tests to Pyenv to make sure that it properly resets the cache whenever we change the active version via any of the real subcommands (in an opaque way so as to not depend on their implementation details).

  • This way, whenever anything in the real subcommands change, we'll automatically verify that the caching logic remains sound!
  • Moreover, pyenv version proper can make use of this shared function, too, thus also taking advantage of the caching!!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The most realistic way I see is to create a shared sourced file in Pyenv with a function that checks "if the active version has changed".

To optimize the effort, we can leave this logic here until we have at least some working solution -- and refactor it out afterwards as a separate step.
So you can save yourself from worrying about this for now!

@native-api
Copy link
Member

P.S. since this is a highly requested change, you are eligible for a payout (non-taxable) from donated money upon completion if you're interested!

Replace content reads ($(< .python-version), which fork a subshell in
bash) with test -nt against a per-session marker file (shell builtin,
zero forks).

Walk the full directory tree from $PWD to / checking .python-version at
each level, matching pyenv's "closest wins" resolution order. Check
directory mtimes to detect file creation/deletion. Check
$PYENV_ROOT/version only when no local .python-version exists anywhere
in the tree.

Skip all disk checks when $PYENV_VERSION is set (shell priority).
@jakelodwick
Copy link
Author

Thanks for the thorough review. The detail on mtime caching and the priority chain was exactly what I needed to get this right.

Revised commit addresses all three points:

  1. Replaced content reads with test -nt against a per-session marker file. The hook no longer reads version file contents at all.
  2. Full directory walk from $PWD to /, checking .python-version at each level. Directory mtimes catch file creation/deletion.
  3. Priority chain: $PYENV_VERSION set → skip all disk checks. Local .python-version found → skip global.

Cache variables simplified from five to three (_PYENV_VH_PWD, _PYENV_VH_VERSION, _PYENV_VH_VENV) plus a marker file ($PYENV_ROOT/.pyenv-vh-marker-$$, 0 bytes, updated with : > in bash/zsh or command touch in fish).

Shared sourced file in pyenv proper left for a follow-up, per your suggestion.

set -g _PYENV_VH_PWD "\$PWD"
set -g _PYENV_VH_VERSION "\$PYENV_VERSION"
set -g _PYENV_VH_VENV "\$VIRTUAL_ENV"
set -g _PYENV_VH_MARKER "\$PYENV_ROOT/.pyenv-vh-marker-\$fish_pid"
Copy link
Member

@native-api native-api Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How are we going to maintain these marker files? We have to delete them at some point, otherwise they'll keep accumulating. I don't have an idea of a reliable way atm, that's why I wrote "somehow".

Is calling stat really that bad? It accepts multiple arguments so a single call would be enough regardless of how many entries we have to check.
Then, it's easy to check for changes by simply comparing outputs:

LOCAL_VERSION_PATHS=<paths to .python-version and dirs if any; can be an array>
SAVED_MTIMES="$(stat -c %Y $LOCAL_VERSION_PATHS)"
<...>
if [[ "$(stat -c %Y $LOCAL_VERSION_PATHS)" != "$SAVED_MTIMES" ]]; then <the cache is stale>; fi

@native-api
Copy link
Member

I'm currently busy with other RL stuff so I may be replying with a delay. Sorry about that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants