feat(python): real pyenv support

If `python` points at a pyenv shim, use `pyenv version-name` to
determine Python version and Venv information.
This commit is contained in:
Brandon Ewing 2022-04-19 22:51:00 -05:00 committed by Jan De Dobbeleer
parent c37733e3ec
commit 4faa73eb2d
5 changed files with 102 additions and 36 deletions

View file

@ -7,7 +7,7 @@ sidebar_label: Python
## What ## What
Display the currently active python version and virtualenv. Display the currently active python version and virtualenv.
Supports conda, virtualenv and pyenv. Supports conda, virtualenv and pyenv (if python points to pyenv shim).
## Sample Configuration ## Sample Configuration
@ -26,7 +26,6 @@ Supports conda, virtualenv and pyenv.
- home_enabled: `boolean` - display the segment in the HOME folder or not - defaults to `false` - home_enabled: `boolean` - display the segment in the HOME folder or not - defaults to `false`
- fetch_virtual_env: `boolean` - fetch the name of the virtualenv or not - defaults to `true` - fetch_virtual_env: `boolean` - fetch the name of the virtualenv or not - defaults to `true`
- use_python_version_file: `boolean` - Use pyenv `.python-version` files to determine virtual env - defaults to `false`
- display_default: `boolean` - show the name of the virtualenv when it's default (`system`, `base`) - display_default: `boolean` - show the name of the virtualenv when it's default (`system`, `base`)
or not - defaults to `true` or not - defaults to `true`
- fetch_version: `boolean` - fetch the python version - defaults to `true` - fetch_version: `boolean` - fetch the python version - defaults to `true`

View file

@ -139,7 +139,9 @@ type Environment interface {
HasFolder(folder string) bool HasFolder(folder string) bool
HasParentFilePath(path string) (fileInfo *FileInfo, err error) HasParentFilePath(path string) (fileInfo *FileInfo, err error)
HasFileInParentDirs(pattern string, depth uint) bool HasFileInParentDirs(pattern string, depth uint) bool
ResolveSymlink(path string) (string, error)
DirMatchesOneOf(dir string, regexes []string) bool DirMatchesOneOf(dir string, regexes []string) bool
CommandPath(command string) string
HasCommand(command string) bool HasCommand(command string) bool
FileContent(file string) string FileContent(file string) string
LsDir(path string) []fs.DirEntry LsDir(path string) []fs.DirEntry
@ -401,6 +403,10 @@ func (env *ShellEnvironment) HasFolder(folder string) bool {
return hasFolder return hasFolder
} }
func (env *ShellEnvironment) ResolveSymlink(path string) (string, error) {
return filepath.EvalSymlinks(path)
}
func (env *ShellEnvironment) FileContent(file string) string { func (env *ShellEnvironment) FileContent(file string) string {
defer env.trace(time.Now(), "FileContent", file) defer env.trace(time.Now(), "FileContent", file)
if !filepath.IsAbs(file) { if !filepath.IsAbs(file) {
@ -500,22 +506,29 @@ func (env *ShellEnvironment) RunShellCommand(shell, command string) string {
return "" return ""
} }
func (env *ShellEnvironment) HasCommand(command string) bool { func (env *ShellEnvironment) CommandPath(command string) string {
defer env.trace(time.Now(), "HasCommand", command) defer env.trace(time.Now(), "HasCommand", command)
if _, ok := env.cmdCache.get(command); ok { if path, ok := env.cmdCache.get(command); ok {
return true return path
} }
path, err := exec.LookPath(command) path, err := exec.LookPath(command)
if err == nil { if err == nil {
env.cmdCache.set(command, path) env.cmdCache.set(command, path)
return true return path
} }
path, err = env.LookWinAppPath(command) path, err = env.LookWinAppPath(command)
if err == nil { if err == nil {
env.cmdCache.set(command, path) env.cmdCache.set(command, path)
return path
}
env.log(Error, "CommandPath", err.Error())
return ""
}
func (env *ShellEnvironment) HasCommand(command string) bool {
if path := env.CommandPath(command); path != "" {
return true return true
} }
env.log(Error, "HasCommand", err.Error())
return false return false
} }

View file

@ -42,6 +42,11 @@ func (env *MockedEnvironment) HasFolder(folder string) bool {
return args.Bool(0) return args.Bool(0)
} }
func (env *MockedEnvironment) ResolveSymlink(path string) (string, error) {
args := env.Called(path)
return args.String(0), args.Error(1)
}
func (env *MockedEnvironment) FileContent(file string) string { func (env *MockedEnvironment) FileContent(file string) string {
args := env.Called(file) args := env.Called(file)
return args.String(0) return args.String(0)
@ -77,6 +82,11 @@ func (env *MockedEnvironment) Platform() string {
return args.String(0) return args.String(0)
} }
func (env *MockedEnvironment) CommandPath(command string) string {
args := env.Called(command)
return args.String(0)
}
func (env *MockedEnvironment) HasCommand(command string) bool { func (env *MockedEnvironment) HasCommand(command string) bool {
args := env.Called(command) args := env.Called(command)
return args.Bool(0) return args.Bool(0)

View file

@ -3,7 +3,7 @@ package segments
import ( import (
"oh-my-posh/environment" "oh-my-posh/environment"
"oh-my-posh/properties" "oh-my-posh/properties"
"oh-my-posh/regex" "path/filepath"
"strings" "strings"
) )
@ -31,6 +31,10 @@ func (p *Python) Init(props properties.Properties, env environment.Environment)
loadContext: p.loadContext, loadContext: p.loadContext,
inContext: p.inContext, inContext: p.inContext,
commands: []*cmd{ commands: []*cmd{
{
getVersion: p.pyenvVersion,
regex: `(?P<version>((?P<major>[0-9]+).(?P<minor>[0-9]+).(?P<patch>[0-9]+)))`,
},
{ {
executable: "python", executable: "python",
args: []string{"--version"}, args: []string{"--version"},
@ -60,7 +64,6 @@ func (p *Python) loadContext() {
"VIRTUAL_ENV", "VIRTUAL_ENV",
"CONDA_ENV_PATH", "CONDA_ENV_PATH",
"CONDA_DEFAULT_ENV", "CONDA_DEFAULT_ENV",
"PYENV_VERSION",
} }
var venv string var venv string
for _, venvVar := range venvVars { for _, venvVar := range venvVars {
@ -71,15 +74,6 @@ func (p *Python) loadContext() {
break break
} }
} }
if !p.language.props.GetBool(UsePythonVersionFile, false) {
return
}
if f, err := p.language.env.HasParentFilePath(".python-version"); err == nil {
contents := strings.Split(p.language.env.FileContent(f.Path), "\n")
if contents[0] != "" && regex.MatchString("[0-9]+.[0-9]+.[0-9]", contents[0]) == false && p.canUseVenvName(contents[0]) {
p.Venv = contents[0]
}
}
} }
func (p *Python) inContext() bool { func (p *Python) inContext() bool {
@ -101,3 +95,48 @@ func (p *Python) canUseVenvName(name string) bool {
} }
return true return true
} }
func (p *Python) pyenvVersion() (string, error) {
// Use `pyenv root` instead of $PYENV_ROOT?
// Is our Python executable at $PYENV_ROOT/bin/python ?
// Should p.env expose command paths?
path := p.env.CommandPath("python")
if path == "" {
path = p.env.CommandPath("python3")
}
if path == "" {
return "", nil
}
// TODO: pyenv-win has this at $PYENV_ROOT/pyenv-win/shims
if path != filepath.Join(p.env.Getenv("PYENV_ROOT"), "shims", "python") {
return "", nil
}
// pyenv version-name will return current version or virtualenv
cmdOutput, err := p.env.RunCommand("pyenv", "version-name")
if err != nil {
// TODO: Improve reporting
return "", nil
}
versionString := strings.Split(cmdOutput, ":")[0]
if versionString == "" {
return "", nil
}
// $PYENV_ROOT/versions + versionString (symlinks resolved) == $PYENV_ROOT/versions/(version)[/envs/(virtualenv)]
realPath, err := p.env.ResolveSymlink(filepath.Join(p.env.Getenv("PYENV_ROOT"), "versions", versionString))
if err != nil {
return "", nil
}
// ../versions/(version)[/envs/(virtualenv)]
shortPath, err := filepath.Rel(filepath.Join(p.env.Getenv("PYENV_ROOT"), "versions"), realPath)
if err != nil {
return "", nil
}
// Unset whatever loadContext thinks Venv should be
p.Venv = ""
parts := strings.Split(shortPath, string(filepath.Separator))
if len(parts) > 2 && p.canUseVenvName(parts[2]) {
p.Venv = parts[2]
}
return parts[0], nil
}

View file

@ -11,6 +11,10 @@ import (
) )
func TestPythonTemplate(t *testing.T) { func TestPythonTemplate(t *testing.T) {
type ResolveSymlink struct {
Path string
Err error
}
cases := []struct { cases := []struct {
Case string Case string
Expected string Expected string
@ -18,7 +22,8 @@ func TestPythonTemplate(t *testing.T) {
Template string Template string
VirtualEnvName string VirtualEnvName string
FetchVersion bool FetchVersion bool
PyenvLocal string PythonPath string
ResolveSymlink ResolveSymlink
}{ }{
{Case: "No virtual env present", FetchVersion: true, Expected: "3.8.4", Template: "{{ if .Venv }}{{ .Venv }} {{ end }}{{ .Full }}"}, {Case: "No virtual env present", FetchVersion: true, Expected: "3.8.4", Template: "{{ if .Venv }}{{ .Venv }} {{ end }}{{ .Full }}"},
{Case: "Virtual env present", FetchVersion: true, Expected: "VENV 3.8.4", VirtualEnvName: "VENV", Template: "{{ if .Venv }}{{ .Venv }} {{ end }}{{ .Full }}"}, {Case: "Virtual env present", FetchVersion: true, Expected: "VENV 3.8.4", VirtualEnvName: "VENV", Template: "{{ if .Venv }}{{ .Venv }} {{ end }}{{ .Full }}"},
@ -44,39 +49,39 @@ func TestPythonTemplate(t *testing.T) {
Template: "{{ if ne .Venv \"default\" }}{{ .Venv }} {{ end }}{{ .Major }}.{{ .Minor }}", Template: "{{ if ne .Venv \"default\" }}{{ .Venv }} {{ end }}{{ .Major }}.{{ .Minor }}",
}, },
{ {
Case: "Pyenv show env", Case: "Pyenv show env",
FetchVersion: true, FetchVersion: true,
Expected: "VENV 3.8", Expected: "VENV 3.8",
PyenvLocal: "VENV\n", PythonPath: "/home/user/.pyenv/shims/python",
Template: "{{ if ne .Venv \"default\" }}{{ .Venv }} {{ end }}{{ .Major }}.{{ .Minor }}", VirtualEnvName: "VENV",
Template: "{{ if ne .Venv \"default\" }}{{ .Venv }} {{ end }}{{ .Major }}.{{ .Minor }}",
ResolveSymlink: ResolveSymlink{Path: "/home/user/.pyenv/versions/3.8.8/envs/VENV", Err: nil},
}, },
{ {
Case: "Pyenv skip version", Case: "Pyenv no venv",
FetchVersion: true, FetchVersion: true,
Expected: "3.8", Expected: "3.8",
PyenvLocal: "3.8.7\n", PythonPath: "/home/user/.pyenv/shims/python",
Template: "{{ if ne .Venv \"default\" }}{{ .Venv }} {{ end }}{{ .Major }}.{{ .Minor }}", Template: "{{ if ne .Venv \"default\" }}{{ .Venv }} {{ end }}{{ .Major }}.{{ .Minor }}",
ResolveSymlink: ResolveSymlink{Path: "/home/user.pyenv/versions/3.8.8", Err: nil},
}, },
} }
for _, tc := range cases { for _, tc := range cases {
env := new(mock.MockedEnvironment) env := new(mock.MockedEnvironment)
env.On("HasCommand", "python").Return(true) env.On("HasCommand", "python").Return(true)
env.On("CommandPath", "mock.Anything").Return(tc.PythonPath)
env.On("RunCommand", "python", []string{"--version"}).Return("Python 3.8.4", nil) env.On("RunCommand", "python", []string{"--version"}).Return("Python 3.8.4", nil)
env.On("RunCommand", "pyenv", []string{"version-name"}).Return(tc.VirtualEnvName, nil)
env.On("HasFiles", "*.py").Return(true) env.On("HasFiles", "*.py").Return(true)
env.On("HasParentFilePath", ".python-version").Return(&environment.FileInfo{
ParentFolder: "/usr/home/project",
Path: "/usr/home/project/.python-version",
IsDir: false,
}, nil)
env.On("FileContent", "/usr/home/project/.python-version").Return(tc.PyenvLocal)
env.On("Getenv", "VIRTUAL_ENV").Return(tc.VirtualEnvName) env.On("Getenv", "VIRTUAL_ENV").Return(tc.VirtualEnvName)
env.On("Getenv", "CONDA_ENV_PATH").Return(tc.VirtualEnvName) env.On("Getenv", "CONDA_ENV_PATH").Return(tc.VirtualEnvName)
env.On("Getenv", "CONDA_DEFAULT_ENV").Return(tc.VirtualEnvName) env.On("Getenv", "CONDA_DEFAULT_ENV").Return(tc.VirtualEnvName)
env.On("Getenv", "PYENV_VERSION").Return(tc.VirtualEnvName) env.On("Getenv", "PYENV_ROOT").Return("/home/user/.pyenv")
env.On("PathSeparator").Return("") env.On("PathSeparator").Return("")
env.On("Pwd").Return("/usr/home/project") env.On("Pwd").Return("/usr/home/project")
env.On("Home").Return("/usr/home") env.On("Home").Return("/usr/home")
env.On("ResolveSymlink", "mock.Anything").Return(tc.ResolveSymlink.Path, tc.ResolveSymlink.Err)
props := properties.Map{ props := properties.Map{
properties.FetchVersion: tc.FetchVersion, properties.FetchVersion: tc.FetchVersion,
UsePythonVersionFile: true, UsePythonVersionFile: true,