Skip to content

Commit 7d0f170

Browse files
authored
Added python.DetectInterpreters and other utils (#805)
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")` To be merged after #804, as one of the steps to get #637 in, as previously discussed.
1 parent f1b068c commit 7d0f170

40 files changed

+575
-535
lines changed

bundle/artifacts/whl/build.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
"github.com/databricks/cli/bundle"
1010
"github.com/databricks/cli/bundle/config"
1111
"github.com/databricks/cli/libs/cmdio"
12-
"github.com/databricks/cli/python"
12+
"github.com/databricks/cli/libs/python"
1313
)
1414

1515
type build struct {

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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package python
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os/exec"
7+
)
8+
9+
func DetectExecutable(ctx context.Context) (string, error) {
10+
// TODO: add a shortcut if .python-version file is detected somewhere in
11+
// the parent directory tree.
12+
//
13+
// See https://github.com/pyenv/pyenv#understanding-python-version-selection
14+
out, err := exec.LookPath("python3")
15+
// most of the OS'es have python3 in $PATH, but for those which don't,
16+
// we perform the latest version lookup
17+
if err != nil && !errors.Is(err, exec.ErrNotFound) {
18+
return "", err
19+
}
20+
if out != "" {
21+
return out, nil
22+
}
23+
// otherwise, detect all interpreters and pick the least that satisfies
24+
// minimal version requirements
25+
all, err := DetectInterpreters(ctx)
26+
if err != nil {
27+
return "", err
28+
}
29+
interpreter, err := all.AtLeast("3.8")
30+
if err != nil {
31+
return "", err
32+
}
33+
return interpreter.Path, nil
34+
}

libs/python/detect_unix_test.go

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

libs/python/detect_win_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//go:build windows
2+
3+
package python
4+
5+
import (
6+
"context"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestDetectsViaPathLookup(t *testing.T) {
13+
ctx := context.Background()
14+
py, err := DetectExecutable(ctx)
15+
assert.NoError(t, err)
16+
assert.NotEmpty(t, py)
17+
}
18+
19+
func TestDetectFailsNoInterpreters(t *testing.T) {
20+
t.Setenv("PATH", "testdata")
21+
ctx := context.Background()
22+
_, err := DetectExecutable(ctx)
23+
assert.ErrorIs(t, err, ErrNoPythonInterpreters)
24+
}

libs/python/interpreters.go

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package python
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io/fs"
8+
"os"
9+
"path/filepath"
10+
"runtime"
11+
"sort"
12+
"strings"
13+
14+
"github.com/databricks/cli/libs/log"
15+
"github.com/databricks/cli/libs/process"
16+
"golang.org/x/mod/semver"
17+
)
18+
19+
var ErrNoPythonInterpreters = errors.New("no python3 interpreters found")
20+
21+
const officialMswinPython = "(Python Official) https://python.org/downloads/windows"
22+
const microsoftStorePython = "(Microsoft Store) https://apps.microsoft.com/store/search?publisher=Python%20Software%20Foundation"
23+
24+
const worldWriteable = 0o002
25+
26+
type Interpreter struct {
27+
Version string
28+
Path string
29+
}
30+
31+
func (i Interpreter) String() string {
32+
return fmt.Sprintf("%s (%s)", i.Version, i.Path)
33+
}
34+
35+
type allInterpreters []Interpreter
36+
37+
func (a allInterpreters) Latest() Interpreter {
38+
return a[len(a)-1]
39+
}
40+
41+
func (a allInterpreters) AtLeast(minimalVersion string) (*Interpreter, error) {
42+
canonicalMinimalVersion := semver.Canonical("v" + strings.TrimPrefix(minimalVersion, "v"))
43+
if canonicalMinimalVersion == "" {
44+
return nil, fmt.Errorf("invalid SemVer: %s", minimalVersion)
45+
}
46+
for _, interpreter := range a {
47+
cmp := semver.Compare(interpreter.Version, canonicalMinimalVersion)
48+
if cmp < 0 {
49+
continue
50+
}
51+
return &interpreter, nil
52+
}
53+
return nil, fmt.Errorf("cannot find Python greater or equal to %s", canonicalMinimalVersion)
54+
}
55+
56+
func DetectInterpreters(ctx context.Context) (allInterpreters, error) {
57+
found := allInterpreters{}
58+
seen := map[string]bool{}
59+
executables, err := pythonicExecutablesFromPathEnvironment(ctx)
60+
if err != nil {
61+
return nil, err
62+
}
63+
log.Debugf(ctx, "found %d potential alternative Python versions in $PATH", len(executables))
64+
for _, resolved := range executables {
65+
if seen[resolved] {
66+
continue
67+
}
68+
seen[resolved] = true
69+
// probe the binary version by executing it, like `python --version`
70+
// and parsing the output.
71+
//
72+
// Keep in mind, that mswin installations get python.exe and pythonw.exe,
73+
// which are slightly different: see https://stackoverflow.com/a/30313091
74+
out, err := process.Background(ctx, []string{resolved, "--version"})
75+
var processErr *process.ProcessError
76+
if errors.As(err, &processErr) {
77+
log.Debugf(ctx, "failed to check version for %s: %s", resolved, processErr.Err)
78+
continue
79+
}
80+
if err != nil {
81+
log.Debugf(ctx, "failed to check version for %s: %s", resolved, err)
82+
continue
83+
}
84+
version := validPythonVersion(ctx, resolved, out)
85+
if version == "" {
86+
continue
87+
}
88+
found = append(found, Interpreter{
89+
Version: version,
90+
Path: resolved,
91+
})
92+
}
93+
if runtime.GOOS == "windows" && len(found) == 0 {
94+
return nil, fmt.Errorf("%w. Install them from %s or %s and restart the shell",
95+
ErrNoPythonInterpreters, officialMswinPython, microsoftStorePython)
96+
}
97+
if len(found) == 0 {
98+
return nil, ErrNoPythonInterpreters
99+
}
100+
sort.Slice(found, func(i, j int) bool {
101+
a := found[i].Version
102+
b := found[j].Version
103+
cmp := semver.Compare(a, b)
104+
if cmp != 0 {
105+
return cmp < 0
106+
}
107+
return a < b
108+
})
109+
return found, nil
110+
}
111+
112+
func pythonicExecutablesFromPathEnvironment(ctx context.Context) (out []string, err error) {
113+
paths := strings.Split(os.Getenv("PATH"), string(os.PathListSeparator))
114+
for _, prefix := range paths {
115+
info, err := os.Stat(prefix)
116+
if errors.Is(err, fs.ErrNotExist) {
117+
// some directories in $PATH may not exist
118+
continue
119+
}
120+
if errors.Is(err, fs.ErrPermission) {
121+
// some directories we cannot list
122+
continue
123+
}
124+
if err != nil {
125+
return nil, fmt.Errorf("stat %s: %w", prefix, err)
126+
}
127+
if !info.IsDir() {
128+
continue
129+
}
130+
perm := info.Mode().Perm()
131+
if runtime.GOOS != "windows" && perm&worldWriteable != 0 {
132+
// we try not to run any python binary that sits in a writable folder by all users.
133+
// this is mainly to avoid breaking the security model on a multi-user system.
134+
// If the PATH is pointing somewhere untrusted it is the user fault, but we can
135+
// help here.
136+
//
137+
// See https://github.com/databricks/cli/pull/805#issuecomment-1735403952
138+
log.Debugf(ctx, "%s is world-writeable (%s), skipping for security reasons", prefix, perm)
139+
continue
140+
}
141+
entries, err := os.ReadDir(prefix)
142+
if errors.Is(err, fs.ErrPermission) {
143+
// some directories we cannot list
144+
continue
145+
}
146+
if err != nil {
147+
return nil, fmt.Errorf("listing %s: %w", prefix, err)
148+
}
149+
for _, v := range entries {
150+
if v.IsDir() {
151+
continue
152+
}
153+
if strings.Contains(v.Name(), "-") {
154+
// skip python3-config, python3.10-config, etc
155+
continue
156+
}
157+
// If Python3 is installed on Windows through GUI installer app that was
158+
// downloaded from https://python.org/downloads/windows, it may appear
159+
// in $PATH as `python`, even though it means Python 2.7 in all other
160+
// operating systems (macOS, Linux).
161+
//
162+
// See https://github.com/databrickslabs/ucx/issues/281
163+
if !strings.HasPrefix(v.Name(), "python") {
164+
continue
165+
}
166+
bin := filepath.Join(prefix, v.Name())
167+
resolved, err := filepath.EvalSymlinks(bin)
168+
if err != nil {
169+
log.Debugf(ctx, "cannot resolve symlink for %s: %s", bin, resolved)
170+
continue
171+
}
172+
out = append(out, resolved)
173+
}
174+
}
175+
return out, nil
176+
}
177+
178+
func validPythonVersion(ctx context.Context, resolved, out string) string {
179+
out = strings.TrimSpace(out)
180+
log.Debugf(ctx, "%s --version: %s", resolved, out)
181+
182+
words := strings.Split(out, " ")
183+
// The Python distribution from the Windows Store is available in $PATH as `python.exe`
184+
// and `python3.exe`, even though it symlinks to a real file packaged with some versions of Windows:
185+
// /c/Program Files/WindowsApps/Microsoft.DesktopAppInstaller_.../AppInstallerPythonRedirector.exe.
186+
// Executing the `python` command from this distribution opens the Windows Store, allowing users to
187+
// download and install Python. Once installed, it replaces the `python.exe` and `python3.exe`` stub
188+
// with the genuine Python executable. Additionally, once user installs from the main installer at
189+
// https://python.org/downloads/windows, it does not replace this stub.
190+
//
191+
// However, a drawback is that if this initial stub is run with any command line arguments, it quietly
192+
// fails to execute. According to https://github.com/databrickslabs/ucx/issues/281, it can be
193+
// detected by seeing just the "Python" output without any version info from the `python --version`
194+
// command execution.
195+
//
196+
// See https://github.com/pypa/packaging-problems/issues/379
197+
// See https://bugs.python.org/issue41327
198+
if len(words) < 2 {
199+
log.Debugf(ctx, "%s --version: stub from Windows Store", resolved)
200+
return ""
201+
}
202+
203+
if words[0] != "Python" {
204+
log.Debugf(ctx, "%s --version: not a Python", resolved)
205+
return ""
206+
}
207+
208+
lastWord := words[len(words)-1]
209+
version := semver.Canonical("v" + lastWord)
210+
if version == "" {
211+
log.Debugf(ctx, "%s --version: invalid SemVer: %s", resolved, lastWord)
212+
return ""
213+
}
214+
215+
return version
216+
}

0 commit comments

Comments
 (0)