Skip to content

Commit e3fc6fb

Browse files
committed
Added python.DetectInterpreters other utils
This PR adds a few utilities related to Python interpreter detection: - `python.DetectInterpreters` to detect all Python versions available in `$PATH` by executing every matched binary name with `--version` flag. - `python.DetectVirtualEnvPath` to detect if there's any child virtual environment in `src` directory - `python.DetectExecutable` to detect if there's python3 installed either by `which python3` command or by calling `python.DetectInterpreters().AtLeast("v3.8")` Code coverage is 95%
1 parent a1238a2 commit e3fc6fb

File tree

23 files changed

+427
-1
lines changed

23 files changed

+427
-1
lines changed

bundle/artifacts/whl/infer.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
"fmt"
66

77
"github.com/databricks/cli/bundle"
8-
"github.com/databricks/cli/python"
8+
"github.com/databricks/cli/libs/python"
99
)
1010

1111
type infer struct {

libs/python/detect.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package python
2+
3+
import (
4+
"context"
5+
"runtime"
6+
"strings"
7+
8+
"github.com/databricks/cli/libs/process"
9+
)
10+
11+
var detectPythonBinaryName = "python3"
12+
13+
func DetectExecutable(ctx context.Context) (string, error) {
14+
// TODO: add a shortcut if .python-version file is detected somewhere in
15+
// the parent directory tree.
16+
//
17+
// See https://github.com/pyenv/pyenv#understanding-python-version-selection
18+
detector := "which"
19+
if runtime.GOOS == "windows" {
20+
detector = "where.exe"
21+
}
22+
out, err := process.Background(ctx, []string{detector, detectPythonBinaryName})
23+
// most of the OS'es have python3 in $PATH, but for those which don't,
24+
// we perform the latest version lookup
25+
if err != nil && !strings.Contains(err.Error(), "not found") {
26+
return "", err
27+
}
28+
if out != "" {
29+
res := strings.Split(out, "\n")
30+
return strings.TrimSpace(res[0]), nil
31+
}
32+
// otherwise, detect all interpreters and pick the least that satisfies
33+
// minimal version requirements
34+
all, err := DetectInterpreters(ctx)
35+
if err != nil {
36+
return "", err
37+
}
38+
interpreter, err := all.AtLeast("3.8")
39+
if err != nil {
40+
return "", err
41+
}
42+
return interpreter.Binary, nil
43+
}

libs/python/detect_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package python
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestDetectsViaWhich(t *testing.T) {
11+
ctx := context.Background()
12+
py, err := DetectExecutable(ctx)
13+
assert.NoError(t, err)
14+
assert.NotEmpty(t, py)
15+
}
16+
17+
func TestDetectsViaListing(t *testing.T) {
18+
t.Setenv("PATH", "testdata/other-binaries-filtered")
19+
ctx := context.Background()
20+
py, err := DetectExecutable(ctx)
21+
assert.NoError(t, err)
22+
assert.Equal(t, "testdata/other-binaries-filtered/python3.10", py)
23+
}
24+
25+
func TestDetectFailsNoInterpreters(t *testing.T) {
26+
t.Setenv("PATH", "testdata")
27+
ctx := context.Background()
28+
_, err := DetectExecutable(ctx)
29+
assert.Equal(t, ErrNoPythonInterpreters, err)
30+
}
31+
32+
func TestDetectFailsNoMinimalVersion(t *testing.T) {
33+
t.Setenv("PATH", "testdata/no-python3")
34+
ctx := context.Background()
35+
_, err := DetectExecutable(ctx)
36+
assert.EqualError(t, err, "cannot find Python greater or equal to v3.8.0")
37+
}

libs/python/interpreters.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package python
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"sort"
10+
"strings"
11+
12+
"github.com/databricks/cli/libs/log"
13+
"github.com/databricks/cli/libs/process"
14+
"golang.org/x/mod/semver"
15+
)
16+
17+
type Interpreter struct {
18+
Version string
19+
Binary string
20+
}
21+
22+
func (i Interpreter) String() string {
23+
return fmt.Sprintf("%s (%s)", i.Version, i.Binary)
24+
}
25+
26+
type allInterpreters []Interpreter
27+
28+
func (a allInterpreters) Latest() Interpreter {
29+
return a[len(a)-1]
30+
}
31+
32+
func (a allInterpreters) AtLeast(minimalVersion string) (*Interpreter, error) {
33+
canonicalMinimalVersion := semver.Canonical("v" + strings.TrimPrefix(minimalVersion, "v"))
34+
if canonicalMinimalVersion == "" {
35+
return nil, fmt.Errorf("invalid SemVer: %s", minimalVersion)
36+
}
37+
for _, interpreter := range a {
38+
cmp := semver.Compare(interpreter.Version, canonicalMinimalVersion)
39+
if cmp < 0 {
40+
continue
41+
}
42+
return &interpreter, nil
43+
}
44+
return nil, fmt.Errorf("cannot find Python greater or equal to %s", canonicalMinimalVersion)
45+
}
46+
47+
var ErrNoPythonInterpreters = errors.New("no python3 interpreters found")
48+
49+
func DetectInterpreters(ctx context.Context) (allInterpreters, error) {
50+
found := allInterpreters{}
51+
paths := strings.Split(os.Getenv("PATH"), string(os.PathListSeparator))
52+
seen := map[string]bool{}
53+
for _, prefix := range paths {
54+
entries, err := os.ReadDir(prefix)
55+
if os.IsNotExist(err) {
56+
// some directories in $PATH may not exist
57+
continue
58+
}
59+
if err != nil {
60+
return nil, fmt.Errorf("listing %s: %w", prefix, err)
61+
}
62+
for _, v := range entries {
63+
if v.IsDir() {
64+
continue
65+
}
66+
if strings.Contains(v.Name(), "-") {
67+
// skip python3-config, python3.10-config, etc
68+
continue
69+
}
70+
71+
// If Python3 is installed on Windows through GUI installer app that was
72+
// downloaded from https://python.org/downloads/windows, it may appear
73+
// in $PATH as `python`, even though it means Python 2.7 in all other
74+
// operating systems (macOS, Linux).
75+
//
76+
// See https://github.com/databrickslabs/ucx/issues/281
77+
if !strings.HasPrefix(v.Name(), "python") {
78+
continue
79+
}
80+
bin := filepath.Join(prefix, v.Name())
81+
resolved, err := filepath.EvalSymlinks(bin)
82+
if err != nil {
83+
log.Debugf(ctx, "cannot resolve symlink for %s: %s", bin, resolved)
84+
continue
85+
}
86+
if seen[resolved] {
87+
continue
88+
}
89+
seen[resolved] = true
90+
91+
// probe the binary version by executing it, like `python --version`
92+
// and parsing the output.
93+
out, err := process.Background(ctx, []string{resolved, "--version"})
94+
if err != nil {
95+
// TODO: skip-and-log or return?
96+
log.Debugf(ctx, "failed to check version for %s: %s", resolved, err)
97+
continue
98+
}
99+
100+
words := strings.Split(out, " ")
101+
// The Python distribution from the Windows Store is available in $PATH as `python.exe`
102+
// and `python3.exe`, even though it symlinks to a real file packaged with some versions of Windows:
103+
// /c/Program Files/WindowsApps/Microsoft.DesktopAppInstaller_.../AppInstallerPythonRedirector.exe.
104+
// Executing the `python` command from this distribution opens the Windows Store, allowing users to
105+
// download and install Python. Once installed, it replaces the `python.exe` and `python3.exe`` stub
106+
// with the genuine Python executable. Additionally, once user installs from the main installer at
107+
// https://python.org/downloads/windows, it does not replace this stub.
108+
//
109+
// However, a drawback is that if this initial stub is run with any command line arguments, it quietly
110+
// fails to execute. According to https://github.com/databrickslabs/ucx/issues/281, it can be
111+
// detected by seeing just the "Python" output without any version info from the `python --version`
112+
// command execution.
113+
//
114+
// See https://github.com/pypa/packaging-problems/issues/379
115+
// See https://bugs.python.org/issue41327
116+
if len(words) < 2 {
117+
continue
118+
}
119+
if words[0] != "Python" {
120+
continue
121+
}
122+
lastWord := words[len(words)-1]
123+
version := semver.Canonical("v" + lastWord)
124+
if version == "" {
125+
continue
126+
}
127+
found = append(found, Interpreter{
128+
Version: version,
129+
Binary: resolved,
130+
})
131+
}
132+
}
133+
if len(found) == 0 {
134+
return nil, ErrNoPythonInterpreters
135+
}
136+
sort.Slice(found, func(i, j int) bool {
137+
a := found[i].Version
138+
b := found[j].Version
139+
cmp := semver.Compare(a, b)
140+
if cmp != 0 {
141+
return cmp < 0
142+
}
143+
return a < b
144+
})
145+
return found, nil
146+
}

libs/python/interpreters_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package python
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestAtLeastOnePythonInstalled(t *testing.T) {
11+
ctx := context.Background()
12+
all, err := DetectInterpreters(ctx)
13+
assert.NoError(t, err)
14+
a := all.Latest()
15+
t.Logf("latest is: %s", a)
16+
assert.True(t, len(all) > 0)
17+
}
18+
19+
func TestNoInterpretersFound(t *testing.T) {
20+
t.Setenv("PATH", t.TempDir())
21+
22+
ctx := context.Background()
23+
all, err := DetectInterpreters(ctx)
24+
assert.Nil(t, all)
25+
assert.Equal(t, ErrNoPythonInterpreters, err)
26+
}
27+
28+
func TestFilteringInterpreters(t *testing.T) {
29+
t.Setenv("PATH", "testdata/other-binaries-filtered")
30+
31+
ctx := context.Background()
32+
all, err := DetectInterpreters(ctx)
33+
assert.NoError(t, err)
34+
assert.Len(t, all, 3)
35+
assert.Equal(t, "v2.7.18", all[0].Version)
36+
assert.Equal(t, "v3.10.5", all[1].Version)
37+
assert.Equal(t, "testdata/other-binaries-filtered/python3.10", all[1].Binary)
38+
assert.Equal(t, "v3.11.4", all[2].Version)
39+
assert.Equal(t, "testdata/other-binaries-filtered/real-python3.11.4", all[2].Binary)
40+
}
41+
42+
func TestInterpretersAtLeastInvalidSemver(t *testing.T) {
43+
t.Setenv("PATH", "testdata/other-binaries-filtered")
44+
45+
ctx := context.Background()
46+
all, err := DetectInterpreters(ctx)
47+
assert.NoError(t, err)
48+
49+
_, err = all.AtLeast("v1.2.3.4")
50+
assert.EqualError(t, err, "invalid SemVer: v1.2.3.4")
51+
}
52+
53+
func TestInterpretersAtLeast(t *testing.T) {
54+
t.Setenv("PATH", "testdata/other-binaries-filtered")
55+
56+
ctx := context.Background()
57+
all, err := DetectInterpreters(ctx)
58+
assert.NoError(t, err)
59+
60+
interpreter, err := all.AtLeast("3.10")
61+
assert.NoError(t, err)
62+
assert.Equal(t, "testdata/other-binaries-filtered/python3.10", interpreter.Binary)
63+
}
64+
65+
func TestInterpretersAtLeastNotSatisfied(t *testing.T) {
66+
t.Setenv("PATH", "testdata/other-binaries-filtered")
67+
68+
ctx := context.Background()
69+
all, err := DetectInterpreters(ctx)
70+
assert.NoError(t, err)
71+
72+
_, err = all.AtLeast("4.0.1")
73+
assert.EqualError(t, err, "cannot find Python greater or equal to v4.0.1")
74+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/bin/sh
2+
3+
# this is an emulation of Windows App Store stub
4+
>&2 echo "Python was not found; run without arguments to install from the Microsoft Store, ..."
5+
6+
echo "Python"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/bin/sh
2+
3+
echo "Python 3.6.4"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/bin/sh
2+
3+
# pythonw is a gui app for launching gui/no-ui-at-all scripts,
4+
# when no console window is opened on Windows
5+
echo "Python 2.7.18"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/bin/sh
2+
3+
# this is an emulation of Windows App Store stub
4+
>&2 echo "Python was not found; run without arguments to install from the Microsoft Store, ..."
5+
6+
echo "Python"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/bin/sh
2+
3+
echo "Must not get executed!"
4+
exit 1

0 commit comments

Comments
 (0)