From 455d1a31db86ede452dd74952ab09c7815cf6970 Mon Sep 17 00:00:00 2001 From: Ayush <63203492+macayu17@users.noreply.github.com> Date: Wed, 3 Jun 2026 03:43:07 +0530 Subject: [PATCH] init: add --install for shell setup (#3454) --- COMMANDS.md | 4 + README.md | 18 ++++ libexec/pyenv-init | 201 +++++++++++++++++++++++++++++++++++++++------ test/init.bats | 128 +++++++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 25 deletions(-) diff --git a/COMMANDS.md b/COMMANDS.md index 320baaeb..6d74f4e9 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -393,11 +393,15 @@ List existing pyenv shims. Configure the shell environment for pyenv Usage: eval "$(pyenv init [-|--path] [--no-push-path] [--no-rehash] [])" + pyenv init --install [] + pyenv init --detect-shell [] - Initialize shims directory, print PYENV_SHELL variable, completions path and shell function --path Print shims path + --install Configure detected shell startup files --no-push-path Do not push shim to the start of PATH if they're already there + --detect-shell Print shell startup files detected for the current shell --no-rehash Add no rehash command to output ## `pyenv completions` diff --git a/README.md b/README.md index f489575a..942f3e09 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,24 @@ which does install native Windows Python versions. The below setup should work for the vast majority of users for common use cases. See [Advanced configuration](#advanced-configuration) for details and more configuration options. +If `pyenv` is already on `PATH`, you can configure the relevant shell startup +files automatically: + +```sh +pyenv init --install +``` + +If `pyenv` is not on `PATH` yet, run the same command through the `pyenv` +executable in your chosen installation directory. + +This uses the same shell detection as `pyenv init`. If a startup file already +contains Pyenv-related configuration, the command refuses to edit it; review the +file manually and run `pyenv init ` to see the suggested setup. + +For Bash, avoid the automatic `--install` path if your `BASH_ENV` points to +`.bashrc`; use the manual Bash instructions below so the `eval "$(pyenv init - bash)"` +line only goes in your login startup file. + #### Bash
diff --git a/libexec/pyenv-init b/libexec/pyenv-init index 36fbf960..b45181fa 100755 --- a/libexec/pyenv-init +++ b/libexec/pyenv-init @@ -1,6 +1,8 @@ #!/usr/bin/env bash # Summary: Configure the shell environment for pyenv -# Usage: eval "$(pyenv init [-|--path] [--no-push-path] [--detect-shell] [--no-rehash] [])" +# Usage: eval "$(pyenv init [-|--path] [--no-push-path] [--no-rehash] [])" +# pyenv init --install [] +# pyenv init --detect-shell [] set -e [ -n "$PYENV_DEBUG" ] && set -x @@ -9,6 +11,7 @@ set -e if [ "$1" = "--complete" ]; then echo - echo --path + echo --install echo --no-push-path echo --no-rehash echo --detect-shell @@ -30,6 +33,9 @@ while [ "$#" -gt 0 ]; do --path) mode="path" ;; + --install) + mode="install" + ;; --detect-shell) mode="detect-shell" ;; @@ -81,21 +87,23 @@ function main() { exit 0 ;; "detect-shell") - detect_profile 1 + detect_profile print_detect_shell exit 0 ;; + "install") + install_shell_startup_files + exit 0 + ;; esac # should never get here exit 2 } function detect_profile() { - local detect_for_detect_shell="$1" - case "$shell" in bash ) - if [ -e '~/.bash_profile' ]; then + if [ -e "${HOME}/.bash_profile" ]; then profile='~/.bash_profile' else profile='~/.profile' @@ -103,6 +111,10 @@ function detect_profile() { profile_explain="~/.bash_profile if it exists, otherwise ~/.profile" rc='~/.bashrc' ;; + fish ) + profile='~/.config/fish/config.fish' + rc='~/.config/fish/config.fish' + ;; pwsh ) profile='~/.config/powershell/profile.ps1' rc='~/.config/powershell/profile.ps1' @@ -121,13 +133,10 @@ function detect_profile() { rc='~/.profile' ;; * ) - if [ -n "$detect_for_detect_shell" ]; then - profile= - rc= - else - profile='your shell'\''s login startup file' - rc='your shell'\''s interactive startup file' - fi + profile= + rc= + profile_explain='your shell'\''s login startup file' + rc_explain='your shell'\''s interactive startup file' ;; esac } @@ -146,38 +155,32 @@ function help_() { echo "# Add pyenv executable to PATH by running" echo "# the following interactively:" echo - echo 'set -Ux PYENV_ROOT $HOME/.pyenv' - echo 'set -U fish_user_paths $PYENV_ROOT/bin $fish_user_paths' + print_fish_user_path_setup echo echo "# Load pyenv automatically by appending" echo "# the following to ~/.config/fish/config.fish:" echo - echo 'pyenv init - fish | source' + print_fish_shell_setup 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")' + print_pwsh_shell_setup ;; * ) echo '# Load pyenv automatically by appending' echo -n "# the following to " - if [ "$profile" == "$rc" ]; then - echo "$profile :" + if [[ "$profile" == "$rc" && -z $rc_explain ]]; then + echo "${profile_explain:-$profile} :" else echo echo "# ${profile_explain:-$profile} (for login shells)" - echo "# and $rc (for interactive shells) :" + echo "# and ${rc_explain:-$rc} (for interactive shells) :" fi echo - echo 'export PYENV_ROOT="$HOME/.pyenv"' - echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' - echo 'eval "$(pyenv init - '$shell')"' + print_posix_shell_setup ;; esac echo @@ -186,6 +189,154 @@ function help_() { } >&2 } +function print_posix_shell_setup() { + echo 'export PYENV_ROOT="$HOME/.pyenv"' + echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' + echo 'eval "$(pyenv init - '$shell')"' +} + +function print_fish_shell_setup() { + echo 'pyenv init - fish | source' +} + +function print_fish_user_path_setup() { + echo 'set -Ux PYENV_ROOT $HOME/.pyenv' + echo 'if functions -q fish_add_path' + echo ' test -d $PYENV_ROOT/bin; and fish_add_path $PYENV_ROOT/bin' + echo 'else' + echo ' test -d $PYENV_ROOT/bin; and set -U fish_user_paths $PYENV_ROOT/bin $fish_user_paths' + echo 'end' +} + +function print_pwsh_shell_setup() { + 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")' +} + +function expand_home_path() { + local path="$1" + printf '%s\n' "${path/#\~/$HOME}" +} + +function install_shell_startup_files() { + if [[ -z $HOME ]]; then + echo "pyenv: HOME must be set to configure shell startup files" >&2 + return 1 + fi + + detect_profile + + local files=() + local lines=() + local profile_path rc_path setup + + case "$shell" in + bash | zsh | ksh | ksh93 | mksh ) + rc_path="$(expand_home_path "$rc")" + profile_path="$(expand_home_path "$profile")" + setup="$(print_posix_shell_setup)" + files=("$rc_path") + lines=("$setup") + if [[ $profile_path != "$rc_path" ]]; then + files+=("$profile_path") + lines+=("$setup") + fi + ;; + fish ) + rc_path="$(expand_home_path "$rc")" + files=("$rc_path") + lines=("$(print_fish_shell_setup)") + ;; + pwsh ) + rc_path="$(expand_home_path "$rc")" + files=("$rc_path") + lines=("$(print_pwsh_shell_setup)") + ;; + * ) + echo "pyenv: cannot automatically configure startup files for $shell" >&2 + return 1 + ;; + esac + + local index + for ((index = 0; index < ${#files[@]}; index++)); do + check_startup_file "${files[$index]}" || return 1 + done + + if [[ $shell == fish ]]; then + install_fish_user_paths || return 1 + fi + + for ((index = 0; index < ${#files[@]}; index++)); do + append_lines "${files[$index]}" "${lines[$index]}" + done +} + +function check_startup_file() { + local file="$1" + local grep_status + + if [[ ! -e $file ]]; then + return 0 + fi + + if [[ ! -f $file || ! -r $file ]]; then + echo "pyenv: failed to inspect $file" >&2 + return 1 + fi + + if grep -Fi pyenv "$file" >/dev/null; then + echo "pyenv: cannot automatically apply changes to $file: it appears to already contain Pyenv-related code." >&2 + echo "pyenv: review the file's contents and apply changes manually if necessary." >&2 + echo "pyenv: run \`pyenv init $shell\` to see the suggested setup." >&2 + return 1 + else + grep_status=$? + if [[ $grep_status == 1 ]]; then + return 0 + fi + echo "pyenv: failed to inspect $file" >&2 + return "$grep_status" + fi +} + +function install_fish_user_paths() { + local fish_setup + + if ! command -v fish >/dev/null; then + echo "pyenv: fish is not available to configure fish universal variables" >&2 + return 1 + fi + + fish_setup="$(print_fish_user_path_setup)" + if ! fish -c "$fish_setup"; then + echo "pyenv: failed to configure fish universal variables" >&2 + return 1 + fi +} + +function append_lines() { + local file="$1" + local lines="$2" + local dir last_char + + dir="${file%/*}" + if [[ $dir != "$file" ]]; then + mkdir -p "$dir" + fi + + if [[ -s $file ]]; then + last_char="$(tail -c 1 "$file")" || return 1 + if [[ -n $last_char ]]; then + echo >> "$file" + fi + fi + + printf '%s\n' "$lines" >> "$file" +} + function init_dirs() { mkdir -p "${PYENV_ROOT}/"{shims,versions} } diff --git a/test/init.bats b/test/init.bats index 2ecd6169..cb1ef20d 100755 --- a/test/init.bats +++ b/test/init.bats @@ -79,6 +79,134 @@ OUT assert_line "PYENV_SHELL_DETECT=bash" } +@test "shell detection for fish startup file" { + run pyenv-init --detect-shell fish + assert_success + assert_line "PYENV_SHELL_DETECT=fish" + assert_line "PYENV_PROFILE_DETECT=~/.config/fish/config.fish" + assert_line "PYENV_RC_DETECT=~/.config/fish/config.fish" +} + +@test "completion includes install option" { + run pyenv-init --complete + assert_success + assert_line "--install" +} + +@test "install setup for detected shell startup files" { + mkdir -p "$HOME" + + run pyenv-init --install + assert_success + + expected_setup=$'export PYENV_ROOT="$HOME/.pyenv"\n[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"\neval "$(pyenv init - bash)"' + assert_equal "$expected_setup" "$(cat "$HOME/.bashrc")" + assert_equal "$expected_setup" "$(cat "$HOME/.profile")" +} + +@test "install setup for bash uses existing bash_profile" { + mkdir -p "$HOME" + touch "$HOME/.bash_profile" + + run pyenv-init --install bash + assert_success + + expected_setup=$'export PYENV_ROOT="$HOME/.pyenv"\n[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"\neval "$(pyenv init - bash)"' + assert_equal "$expected_setup" "$(cat "$HOME/.bashrc")" + assert_equal "$expected_setup" "$(cat "$HOME/.bash_profile")" + assert [ ! -e "$HOME/.profile" ] +} + +@test "install setup for zsh startup files" { + mkdir -p "$HOME" + + run pyenv-init --install zsh + assert_success + + expected_setup=$'export PYENV_ROOT="$HOME/.pyenv"\n[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"\neval "$(pyenv init - zsh)"' + assert_equal "$expected_setup" "$(cat "$HOME/.zshrc")" + assert_equal "$expected_setup" "$(cat "$HOME/.zprofile")" +} + +@test "install setup for fish startup file" { + mkdir -p "$HOME" + create_stub fish < "$PYENV_TEST_DIR/fish-script" +OUT + + run pyenv-init --install fish + assert_success + + expected_fish_script=$'set -Ux PYENV_ROOT $HOME/.pyenv\nif functions -q fish_add_path\n test -d $PYENV_ROOT/bin; and fish_add_path $PYENV_ROOT/bin\nelse\n test -d $PYENV_ROOT/bin; and set -U fish_user_paths $PYENV_ROOT/bin $fish_user_paths\nend' + expected_setup='pyenv init - fish | source' + assert_equal "$expected_fish_script" "$(cat "$PYENV_TEST_DIR/fish-script")" + assert_equal "$expected_setup" "$(cat "$HOME/.config/fish/config.fish")" +} + +@test "install setup for pwsh startup file" { + mkdir -p "$HOME" + + run pyenv-init --install pwsh + assert_success + + expected_setup=$'$Env:PYENV_ROOT="$Env:HOME/.pyenv"\nif (Test-Path -LP "$Env:PYENV_ROOT/bin" -PathType Container) {\n $Env:PATH="$Env:PYENV_ROOT/bin:$Env:PATH" }\niex ((pyenv init -) -join "`n")' + assert_equal "$expected_setup" "$(cat "$HOME/.config/powershell/profile.ps1")" +} + +@test "install refuses to modify files with pyenv-related code" { + mkdir -p "$HOME" + echo 'eval "$(pyenv init -)"' > "$HOME/.bashrc" + + run pyenv-init --install bash + assert_failure + assert_line "pyenv: cannot automatically apply changes to $HOME/.bashrc: it appears to already contain Pyenv-related code." + assert_line "pyenv: review the file's contents and apply changes manually if necessary." + assert_line "pyenv: run \`pyenv init bash\` to see the suggested setup." + + assert_equal 'eval "$(pyenv init -)"' "$(cat "$HOME/.bashrc")" + assert [ ! -e "$HOME/.profile" ] +} + +@test "install treats PYENV_ROOT as pyenv-related code" { + mkdir -p "$HOME" + echo 'export PYENV_ROOT="$HOME/tools/python-env"' > "$HOME/.bashrc" + + run pyenv-init --install bash + assert_failure + assert_line "pyenv: cannot automatically apply changes to $HOME/.bashrc: it appears to already contain Pyenv-related code." +} + +@test "install refuses unreadable startup file without partial writes" { + mkdir -p "$HOME/.bashrc" + + run pyenv-init --install bash + assert_failure + assert_line "pyenv: failed to inspect $HOME/.bashrc" + + assert [ ! -e "$HOME/.profile" ] +} + +@test "install setup keeps fish block intact when generic lines already exist" { + mkdir -p "$HOME/.config/fish" + create_stub fish < "$HOME/.config/fish/config.fish" + + run pyenv-init --install fish + assert_success + + expected_setup=$'end\npyenv init - fish | source' + assert_equal "$expected_setup" "$(cat "$HOME/.config/fish/config.fish")" +} + +@test "install setup fails gracefully for unsupported shell" { + mkdir -p "$HOME" + + run pyenv-init --install nu + assert_failure "pyenv: cannot automatically configure startup files for nu" +} + @test "option to skip rehash" { run pyenv-init - --no-rehash assert_success