Repository: JanDeDobbeleer/oh-my-posh
Pull request #2063 (2022-04-09 19:11:48)

feat(python): support pyenv .python-version files
Pyenv will use .python-version files up the file heirarchy when
determining the active Python version or virtual environment to use
based on which folder your shell is in.  This change will read the file,
and if the first line does not look like a Python version, set the Venv
to that string for display.

This feature is gated behind the `use_python_version_file` Property


### Prerequisites

- [x] I have read and understood the `CONTRIBUTING` guide
- [x] The commit message follows the [conventional commits][cc] guidelines
- [x] Tests for the changes have been added (for bug fixes/features)
- [x] Docs have been added / updated (for bug fixes/features)

### Description
Read .python-version files to determine virtualenv if environment variable search turns up empty.
Note:  Does not read $PYENV_ROOT/version -- should we?
Question:  Would it be better to just parse `pyenv version` directly to determine the first environment?
Finally:  I'm using regex to try to not display a global python version as the python virtualenv -- my first thought was to compare the returned string to `python.language.version.Full`, but apparently that's not populated when we're detecting the Venv.

[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary


JanDeDobbeleer: @bewing I can do the changes if you don't have time, would love to merge this one.
bewing: > @bewing I can do the changes if you don't have time, would love to merge this one.

I think this change is useful enough on its own -- I will add a config gate and documentation today.

Patch:
diff --git a/docs/docs/segment-python.md b/docs/docs/segment-python.md
index 1693fb8b..6cdfdc62 100644
--- a/docs/docs/segment-python.md
+++ b/docs/docs/segment-python.md
@@ -26,6 +26,7 @@ Supports conda, virtualenv and pyenv.
 
 - 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`
+- 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`)
 or not - defaults to `true`
 - fetch_version: `boolean` - fetch the python version - defaults to `true`
diff --git a/src/segments/python.go b/src/segments/python.go
index 96f8a67c..4fc6ea6e 100644
--- a/src/segments/python.go
+++ b/src/segments/python.go
@@ -3,6 +3,8 @@ package segments
 import (
 	"oh-my-posh/environment"
 	"oh-my-posh/properties"
+	"oh-my-posh/regex"
+	"strings"
 )
 
 type Python struct {
@@ -13,7 +15,8 @@ type Python struct {
 
 const (
 	// FetchVirtualEnv fetches the virtual env
-	FetchVirtualEnv properties.Property = "fetch_virtual_env"
+	FetchVirtualEnv      properties.Property = "fetch_virtual_env"
+	UsePythonVersionFile properties.Property = "use_python_version_file"
 )
 
 func (p *Python) Template() string {
@@ -68,6 +71,15 @@ func (p *Python) loadContext() {
 			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 {


Test patch:
diff --git a/src/segments/python_test.go b/src/segments/python_test.go
index 4dd2a16b..62066c5f 100644
--- a/src/segments/python_test.go
+++ b/src/segments/python_test.go
@@ -1,6 +1,7 @@
 package segments
 
 import (
+	"errors"
 	"oh-my-posh/environment"
 	"oh-my-posh/mock"
 	"oh-my-posh/properties"
@@ -17,6 +18,7 @@ func TestPythonTemplate(t *testing.T) {
 		Template         string
 		VirtualEnvName   string
 		FetchVersion     bool
+		PyenvLocal       string
 	}{
 		{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 }}"},
@@ -41,6 +43,20 @@ func TestPythonTemplate(t *testing.T) {
 			VirtualEnvName: "billy",
 			Template:       "{{ if ne .Venv \"default\" }}{{ .Venv }} {{ end }}{{ .Major }}.{{ .Minor }}",
 		},
+		{
+			Case:         "Pyenv show env",
+			FetchVersion: true,
+			Expected:     "VENV 3.8",
+			PyenvLocal:   "VENV\n",
+			Template:     "{{ if ne .Venv \"default\" }}{{ .Venv }} {{ end }}{{ .Major }}.{{ .Minor }}",
+		},
+		{
+			Case:         "Pyenv skip version",
+			FetchVersion: true,
+			Expected:     "3.8",
+			PyenvLocal:   "3.8.7\n",
+			Template:     "{{ if ne .Venv \"default\" }}{{ .Venv }} {{ end }}{{ .Major }}.{{ .Minor }}",
+		},
 	}
 
 	for _, tc := range cases {
@@ -48,6 +64,12 @@ func TestPythonTemplate(t *testing.T) {
 		env.On("HasCommand", "python").Return(true)
 		env.On("RunCommand", "python", []string{"--version"}).Return("Python 3.8.4", nil)
 		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", "CONDA_ENV_PATH").Return(tc.VirtualEnvName)
 		env.On("Getenv", "CONDA_DEFAULT_ENV").Return(tc.VirtualEnvName)
@@ -57,6 +79,7 @@ func TestPythonTemplate(t *testing.T) {
 		env.On("Home").Return("/usr/home")
 		props := properties.Map{
 			properties.FetchVersion: tc.FetchVersion,
+			UsePythonVersionFile:    true,
 			DisplayMode:             DisplayModeAlways,
 		}
 		env.On("TemplateCache").Return(&environment.TemplateCache{
@@ -85,6 +108,7 @@ func TestPythonPythonInContext(t *testing.T) {
 		env.On("Getenv", "CONDA_ENV_PATH").Return("")
 		env.On("Getenv", "CONDA_DEFAULT_ENV").Return("")
 		env.On("Getenv", "PYENV_VERSION").Return("")
+		env.On("HasParentFilePath", ".python-version").Return(&environment.FileInfo{}, errors.New("no match at root level"))
 		python := &Python{}
 		python.Init(properties.Map{}, env)
 		python.loadContext()