From aeea3ac825cd667cc9c24e8fb405f6b78251491e Mon Sep 17 00:00:00 2001 From: Florian Blanchet Date: Sun, 21 Dec 2025 04:33:19 +0200 Subject: [PATCH] Add PowerShell support (#2749) Co-authored-by: Ivan Pozdeev Co-authored-by: Ivan Pozdeev --- README.md | 16 +++++++++- completions/pyenv.pwsh | 17 ++++++++++ libexec/pyenv-init | 70 ++++++++++++++++++++++++++++++++++++++--- libexec/pyenv-sh-rehash | 12 +++++-- libexec/pyenv-sh-shell | 16 ++++++++++ test/init.bats | 66 ++++++++++++++++++++++++++++++++++++++ test/rehash.bats | 20 ++++++++++++ test/shell.bats | 28 +++++++++++++++++ test/test_helper.bash | 10 ++++-- 9 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 completions/pyenv.pwsh diff --git a/README.md b/README.md index 5d43b415..f43c4aa1 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,20 @@ See [Advanced configuration](#advanced-configuration) for details and more confi +#### Microsoft PowerShell + +
+ + Add the commands to `$profile.CurrentUserAllHosts` by running the following in your terminal: + + ~~~ pwsh + echo '$Env:PYENV_ROOT="$Env:HOME/.pyenv"' >> $profile.CurrentUserAllHosts + echo 'if (Test-Path -LP "$Env:PYENV_ROOT/bin" -PathType Container) { + $Env:PATH="$Env:PYENV_ROOT/bin:$Env:PATH" }' >> $profile.CurrentUserAllHosts + echo 'iex ((pyenv init -) -join "`n")' >> $profile.CurrentUserAllHosts + ~~~ + +
### C. Restart your shell ---- @@ -689,7 +703,7 @@ opposed to this idea. Here's what `eval "$(pyenv init -)"` actually does: 3. **Installs autocompletion.** This is entirely optional but pretty useful. Sourcing `/completions/pyenv.bash` will set that - up. There are also completions for Zsh and Fish. + up. There are also completions for Zsh, Fish and PowerShell. 4. **Rehashes shims.** From time to time you'll need to rebuild your shim files. Doing this on init makes sure everything is up to diff --git a/completions/pyenv.pwsh b/completions/pyenv.pwsh new file mode 100644 index 00000000..f422f468 --- /dev/null +++ b/completions/pyenv.pwsh @@ -0,0 +1,17 @@ +$scriptblock = { + param($wordToComplete, $commandAst, $cursorPosition) + $words = $commandAst.ToString() + if ( $wordToComplete ) { + $matches = (($words[0..$cursorPosition] -join '') | Select-String -Pattern "\s+" -AllMatches).Matches + if ( $matches ) { + $cursorPosition = $matches[-1].Index - 1 + } + } + $words = $words[0..$cursorPosition] -join '' -split "\s+" + if ( $words.Count -ge 2 ) { + pyenv completions $words[1] | where { $_ -match $wordToComplete } + } else { + pyenv commands | where { $_ -match $wordToComplete } + } +} +Register-ArgumentCompleter -Native -CommandName pyenv -ScriptBlock $scriptblock diff --git a/libexec/pyenv-init b/libexec/pyenv-init index 6341b655..757a443f 100755 --- a/libexec/pyenv-init +++ b/libexec/pyenv-init @@ -15,6 +15,7 @@ if [ "$1" = "--complete" ]; then echo bash echo fish echo ksh + echo pwsh echo zsh exit fi @@ -102,6 +103,10 @@ function detect_profile() { profile_explain="~/.bash_profile if it exists, otherwise ~/.profile" rc='~/.bashrc' ;; + pwsh ) + profile='~/.config/powershell/profile.ps1' + rc='~/.config/powershell/profile.ps1' + ;; zsh ) profile='~/.zprofile' rc='~/.zshrc' @@ -150,6 +155,15 @@ function help_() { echo 'pyenv init - fish | source' echo ;; + pwsh ) + echo '# Load pyenv automatically by appending' + echo "# the following to $profile :" + echo + echo '$Env:PYENV_ROOT="$Env:HOME/.pyenv"' + echo 'if (Test-Path -LP "$Env:PYENV_ROOT/bin" -PathType Container) {' + echo ' $Env:PATH="$Env:PYENV_ROOT/bin:$Env:PATH" }' + echo 'iex ((pyenv init -) -join "`n")' + ;; * ) echo '# Load pyenv automatically by appending' echo -n "# the following to " @@ -186,6 +200,11 @@ function print_path() { print_path_prepend_shims echo 'end' ;; + pwsh ) + echo 'if ( $Env:PATH -notmatch "'"${PYENV_ROOT}/shims"'" ) {' + print_path_prepend_shims + echo '}' + ;; * ) echo 'if [[ ":$PATH:" != *'\':"${PYENV_ROOT}"/shims:\''* ]]; then' print_path_prepend_shims @@ -199,6 +218,10 @@ function print_path() { echo 'set -eg PATH[$pyenv_index]; end; set -e pyenv_index' print_path_prepend_shims ;; + pwsh ) + echo '$Env:PATH="$(($Env:PATH -split '"':'"' | where { -not ($_ -match '"'${PYENV_ROOT}/shims'"') }) -join '"':'"')"' + print_path_prepend_shims + ;; * ) # Some distros (notably Debian-based) set Bash's SSH_SOURCE_BASHRC compilation option # that makes it source `bashrc` under SSH even when not interactive. @@ -223,6 +246,9 @@ function print_path_prepend_shims() { fish ) echo 'set -gx PATH '\'"${PYENV_ROOT}/shims"\'' $PATH' ;; + pwsh ) + echo '$Env:PATH="'"${PYENV_ROOT}"'/shims:$Env:PATH"' + ;; * ) echo 'export PATH="'"${PYENV_ROOT}"'/shims:${PATH}"' ;; @@ -234,6 +260,9 @@ function print_env() { fish ) echo "set -gx PYENV_SHELL $shell" ;; + pwsh ) + echo '$Env:PYENV_SHELL="'"$shell"'"' + ;; * ) echo "export PYENV_SHELL=$shell" ;; @@ -243,13 +272,27 @@ function print_env() { function print_completion() { completion="${0%/*/*}/completions/pyenv.${shell}" if [ -r "$completion" ]; then - echo "source '$completion'" + case "$shell" in + pwsh ) + echo "iex (gc $completion -Raw)" + ;; + * ) + echo "source '$completion'" + ;; + esac fi } function print_rehash() { if [ -z "$no_rehash" ]; then - echo 'command pyenv rehash 2>/dev/null' + case "$shell" in + pwsh ) + echo '& pyenv rehash 2>/dev/null' + ;; + * ) + echo 'command pyenv rehash 2>/dev/null' + ;; + esac fi } @@ -270,6 +313,25 @@ function print_shell_function() { end end' ;; + pwsh ) + cat </dev/null || true" diff --git a/libexec/pyenv-sh-shell b/libexec/pyenv-sh-shell index 51352e1d..289ba424 100755 --- a/libexec/pyenv-sh-shell +++ b/libexec/pyenv-sh-shell @@ -48,6 +48,9 @@ if [ "$versions" = "--unset" ]; then echo 'set -gu PYENV_VERSION_OLD "$PYENV_VERSION"' echo "set -e PYENV_VERSION" ;; + pwsh ) + echo '$Env:PYENV_VERSION, $Env:PYENV_VERSION_OLD = $null, $Env:PYENV_VERSION' + ;; * ) echo 'PYENV_VERSION_OLD="${PYENV_VERSION-}"' echo "unset PYENV_VERSION" @@ -74,6 +77,16 @@ else echo "pyenv: PYENV_VERSION_OLD is not set" >&2 false end +EOS + ;; + pwsh ) + cat </dev/null; then echo 'set -gu PYENV_VERSION_OLD "$PYENV_VERSION"' echo "set -gx PYENV_VERSION \"$version\"" ;; + pwsh ) + echo '$Env:PYENV_VERSION, $Env:PYENV_VERSION_OLD = "'"${version}"'", $Env:PYENV_VERSION' + ;; * ) echo 'PYENV_VERSION_OLD="${PYENV_VERSION-}"' echo "export PYENV_VERSION=\"${version}\"" diff --git a/test/init.bats b/test/init.bats index 2b963249..85ef01ec 100755 --- a/test/init.bats +++ b/test/init.bats @@ -62,6 +62,19 @@ OUT assert_line 'pyenv init - fish | source' } +@test "setup shell completions (pwsh)" { + root="$(cd $BATS_TEST_DIRNAME/.. && pwd)" + run pyenv-init - pwsh + assert_success + assert_line "iex (gc ${root}/completions/pyenv.pwsh -Raw)" +} + +@test "pwsh instructions" { + run pyenv-init pwsh + assert [ "$status" -eq 1 ] + assert_line 'iex ((pyenv init -) -join "`n")' +} + @test "shell detection for installer" { run pyenv-init --detect-shell assert_success @@ -88,6 +101,13 @@ OUT assert_line "set -gx PATH '${PYENV_ROOT}/shims' \$PATH" } +@test "adds shims to PATH (pwsh)" { + export PATH="${BATS_TEST_DIRNAME}/../libexec:/usr/bin:/bin:/usr/local/bin" + run pyenv-init - pwsh + assert_success + assert_line '$Env:PATH="'${PYENV_ROOT}'/shims:$Env:PATH"' +} + @test "removes existing shims from PATH" { OLDPATH="$PATH" export PATH="${BATS_TEST_DIRNAME}/nonexistent:${PYENV_ROOT}/shims:$PATH" @@ -112,6 +132,19 @@ echo "\$PATH" assert_output "${PYENV_ROOT}/shims:${BATS_TEST_DIRNAME}/nonexistent:${OLDPATH//${PYENV_ROOT}\/shims:/}" } +@test "removes existing shims from PATH (pwsh)" { + command -v pwsh >/dev/null || skip "-- pwsh not installed" + OLDPATH="$PATH" + export PATH="${BATS_TEST_DIRNAME}/nonexistent:${PYENV_ROOT}/shims:$PATH" + run pwsh -noni -c - </dev/null || skip "-- pwsh not installed" + PATH="${BATS_TEST_DIRNAME}/../libexec:/usr/bin:/bin:/usr/local/bin" + run pwsh -nop -c - </dev/null || skip "-- pwsh not installed" + PATH="${BATS_TEST_DIRNAME}/../libexec:/usr/bin:${PYENV_ROOT}/shims:/bin:/usr/local/bin" + run pwsh -nop -c - </dev/null || true" assert [ -x "${PYENV_ROOT}/shims/python" ] } +@test "sh-rehash in pwsh" { + create_alt_executable_in_version "3.4" "python" + PYENV_SHELL=pwsh run pyenv-sh-rehash + assert_success "& (get-command pyenv -commandtype application) rehash" +} + +@test "sh-rehash in pwsh (integration)" { + command -v pwsh >/dev/null || skip "-- pwsh not installed" + assert [ ! -x "${PYENV_ROOT}/shims/python" ] + create_alt_executable_in_version "3.4" "python" + run pwsh -nop -c -<<'!' +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true +iex ((& pyenv init - pwsh) -join "`n") +& pyenv rehash +! + assert_success "" + assert [ -x "${PYENV_ROOT}/shims/python" ] +} + @test "shim sets _PYENV_SHIM_PATH when linked from elsewhere" { export PYENV_VERSION="custom" create_alt_executable python3 diff --git a/test/shell.bats b/test/shell.bats index daa5744b..e20df561 100644 --- a/test/shell.bats +++ b/test/shell.bats @@ -31,6 +31,11 @@ load test_helper assert_success 'echo "$PYENV_VERSION"' } +@test "shell version (pwsh)" { + PYENV_SHELL=pwsh PYENV_VERSION="1.2.3" run pyenv-sh-shell + assert_success 'echo "$PYENV_VERSION"' +} + @test "shell revert" { PYENV_SHELL=bash run pyenv-sh-shell - assert_success @@ -43,6 +48,12 @@ load test_helper assert_line 0 'if set -q PYENV_VERSION_OLD' } +@test "shell revert (pwsh)" { + PYENV_SHELL=pwsh run pyenv-sh-shell - + assert_success + assert_line 0 'if ( Get-Item -Path Env:\PYENV_VERSION* ) {' +} + @test "shell unset" { PYENV_SHELL=bash run pyenv-sh-shell --unset assert_success @@ -61,6 +72,14 @@ set -e PYENV_VERSION OUT } +@test "shell unset (pwsh)" { + PYENV_SHELL=pwsh run pyenv-sh-shell --unset + assert_success + assert_output </dev/null | grep ^XDG_ | cut -d= -f1`; do unset "$xdg_var"; done unset xdg_var + # Workaround for Powershell. When tests are run from a terminal, + # and running a script fron a here-document, + # Powershell 7.5.4 erroneously prints ANSI escape sequences + # even if its output is redirected, breaking the comparison logic + export NO_COLOR=1 + # If test specific setup exist, run it if [[ $(type -t _setup) == function ]];then _setup @@ -67,8 +73,8 @@ assert_failure() { assert_equal() { if [ "$1" != "$2" ]; then - { echo "expected: $1" - echo "actual: $2" + { echo "expected: \`$1'" + echo "actual: \`$2'" } | flunk fi }