mirror of https://github.com/databricks/cli.git
217 lines
6.7 KiB
Go
217 lines
6.7 KiB
Go
|
package python
|
||
|
|
||
|
import (
|
||
|
"context"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"io/fs"
|
||
|
"os"
|
||
|
"path/filepath"
|
||
|
"runtime"
|
||
|
"sort"
|
||
|
"strings"
|
||
|
|
||
|
"github.com/databricks/cli/libs/log"
|
||
|
"github.com/databricks/cli/libs/process"
|
||
|
"golang.org/x/mod/semver"
|
||
|
)
|
||
|
|
||
|
var ErrNoPythonInterpreters = errors.New("no python3 interpreters found")
|
||
|
|
||
|
const officialMswinPython = "(Python Official) https://python.org/downloads/windows"
|
||
|
const microsoftStorePython = "(Microsoft Store) https://apps.microsoft.com/store/search?publisher=Python%20Software%20Foundation"
|
||
|
|
||
|
const worldWriteable = 0o002
|
||
|
|
||
|
type Interpreter struct {
|
||
|
Version string
|
||
|
Path string
|
||
|
}
|
||
|
|
||
|
func (i Interpreter) String() string {
|
||
|
return fmt.Sprintf("%s (%s)", i.Version, i.Path)
|
||
|
}
|
||
|
|
||
|
type allInterpreters []Interpreter
|
||
|
|
||
|
func (a allInterpreters) Latest() Interpreter {
|
||
|
return a[len(a)-1]
|
||
|
}
|
||
|
|
||
|
func (a allInterpreters) AtLeast(minimalVersion string) (*Interpreter, error) {
|
||
|
canonicalMinimalVersion := semver.Canonical("v" + strings.TrimPrefix(minimalVersion, "v"))
|
||
|
if canonicalMinimalVersion == "" {
|
||
|
return nil, fmt.Errorf("invalid SemVer: %s", minimalVersion)
|
||
|
}
|
||
|
for _, interpreter := range a {
|
||
|
cmp := semver.Compare(interpreter.Version, canonicalMinimalVersion)
|
||
|
if cmp < 0 {
|
||
|
continue
|
||
|
}
|
||
|
return &interpreter, nil
|
||
|
}
|
||
|
return nil, fmt.Errorf("cannot find Python greater or equal to %s", canonicalMinimalVersion)
|
||
|
}
|
||
|
|
||
|
func DetectInterpreters(ctx context.Context) (allInterpreters, error) {
|
||
|
found := allInterpreters{}
|
||
|
seen := map[string]bool{}
|
||
|
executables, err := pythonicExecutablesFromPathEnvironment(ctx)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
log.Debugf(ctx, "found %d potential alternative Python versions in $PATH", len(executables))
|
||
|
for _, resolved := range executables {
|
||
|
if seen[resolved] {
|
||
|
continue
|
||
|
}
|
||
|
seen[resolved] = true
|
||
|
// probe the binary version by executing it, like `python --version`
|
||
|
// and parsing the output.
|
||
|
//
|
||
|
// Keep in mind, that mswin installations get python.exe and pythonw.exe,
|
||
|
// which are slightly different: see https://stackoverflow.com/a/30313091
|
||
|
out, err := process.Background(ctx, []string{resolved, "--version"})
|
||
|
var processErr *process.ProcessError
|
||
|
if errors.As(err, &processErr) {
|
||
|
log.Debugf(ctx, "failed to check version for %s: %s", resolved, processErr.Err)
|
||
|
continue
|
||
|
}
|
||
|
if err != nil {
|
||
|
log.Debugf(ctx, "failed to check version for %s: %s", resolved, err)
|
||
|
continue
|
||
|
}
|
||
|
version := validPythonVersion(ctx, resolved, out)
|
||
|
if version == "" {
|
||
|
continue
|
||
|
}
|
||
|
found = append(found, Interpreter{
|
||
|
Version: version,
|
||
|
Path: resolved,
|
||
|
})
|
||
|
}
|
||
|
if runtime.GOOS == "windows" && len(found) == 0 {
|
||
|
return nil, fmt.Errorf("%w. Install them from %s or %s and restart the shell",
|
||
|
ErrNoPythonInterpreters, officialMswinPython, microsoftStorePython)
|
||
|
}
|
||
|
if len(found) == 0 {
|
||
|
return nil, ErrNoPythonInterpreters
|
||
|
}
|
||
|
sort.Slice(found, func(i, j int) bool {
|
||
|
a := found[i].Version
|
||
|
b := found[j].Version
|
||
|
cmp := semver.Compare(a, b)
|
||
|
if cmp != 0 {
|
||
|
return cmp < 0
|
||
|
}
|
||
|
return a < b
|
||
|
})
|
||
|
return found, nil
|
||
|
}
|
||
|
|
||
|
func pythonicExecutablesFromPathEnvironment(ctx context.Context) (out []string, err error) {
|
||
|
paths := strings.Split(os.Getenv("PATH"), string(os.PathListSeparator))
|
||
|
for _, prefix := range paths {
|
||
|
info, err := os.Stat(prefix)
|
||
|
if errors.Is(err, fs.ErrNotExist) {
|
||
|
// some directories in $PATH may not exist
|
||
|
continue
|
||
|
}
|
||
|
if errors.Is(err, fs.ErrPermission) {
|
||
|
// some directories we cannot list
|
||
|
continue
|
||
|
}
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("stat %s: %w", prefix, err)
|
||
|
}
|
||
|
if !info.IsDir() {
|
||
|
continue
|
||
|
}
|
||
|
perm := info.Mode().Perm()
|
||
|
if runtime.GOOS != "windows" && perm&worldWriteable != 0 {
|
||
|
// we try not to run any python binary that sits in a writable folder by all users.
|
||
|
// this is mainly to avoid breaking the security model on a multi-user system.
|
||
|
// If the PATH is pointing somewhere untrusted it is the user fault, but we can
|
||
|
// help here.
|
||
|
//
|
||
|
// See https://github.com/databricks/cli/pull/805#issuecomment-1735403952
|
||
|
log.Debugf(ctx, "%s is world-writeable (%s), skipping for security reasons", prefix, perm)
|
||
|
continue
|
||
|
}
|
||
|
entries, err := os.ReadDir(prefix)
|
||
|
if errors.Is(err, fs.ErrPermission) {
|
||
|
// some directories we cannot list
|
||
|
continue
|
||
|
}
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("listing %s: %w", prefix, err)
|
||
|
}
|
||
|
for _, v := range entries {
|
||
|
if v.IsDir() {
|
||
|
continue
|
||
|
}
|
||
|
if strings.Contains(v.Name(), "-") {
|
||
|
// skip python3-config, python3.10-config, etc
|
||
|
continue
|
||
|
}
|
||
|
// If Python3 is installed on Windows through GUI installer app that was
|
||
|
// downloaded from https://python.org/downloads/windows, it may appear
|
||
|
// in $PATH as `python`, even though it means Python 2.7 in all other
|
||
|
// operating systems (macOS, Linux).
|
||
|
//
|
||
|
// See https://github.com/databrickslabs/ucx/issues/281
|
||
|
if !strings.HasPrefix(v.Name(), "python") {
|
||
|
continue
|
||
|
}
|
||
|
bin := filepath.Join(prefix, v.Name())
|
||
|
resolved, err := filepath.EvalSymlinks(bin)
|
||
|
if err != nil {
|
||
|
log.Debugf(ctx, "cannot resolve symlink for %s: %s", bin, resolved)
|
||
|
continue
|
||
|
}
|
||
|
out = append(out, resolved)
|
||
|
}
|
||
|
}
|
||
|
return out, nil
|
||
|
}
|
||
|
|
||
|
func validPythonVersion(ctx context.Context, resolved, out string) string {
|
||
|
out = strings.TrimSpace(out)
|
||
|
log.Debugf(ctx, "%s --version: %s", resolved, out)
|
||
|
|
||
|
words := strings.Split(out, " ")
|
||
|
// The Python distribution from the Windows Store is available in $PATH as `python.exe`
|
||
|
// and `python3.exe`, even though it symlinks to a real file packaged with some versions of Windows:
|
||
|
// /c/Program Files/WindowsApps/Microsoft.DesktopAppInstaller_.../AppInstallerPythonRedirector.exe.
|
||
|
// Executing the `python` command from this distribution opens the Windows Store, allowing users to
|
||
|
// download and install Python. Once installed, it replaces the `python.exe` and `python3.exe`` stub
|
||
|
// with the genuine Python executable. Additionally, once user installs from the main installer at
|
||
|
// https://python.org/downloads/windows, it does not replace this stub.
|
||
|
//
|
||
|
// However, a drawback is that if this initial stub is run with any command line arguments, it quietly
|
||
|
// fails to execute. According to https://github.com/databrickslabs/ucx/issues/281, it can be
|
||
|
// detected by seeing just the "Python" output without any version info from the `python --version`
|
||
|
// command execution.
|
||
|
//
|
||
|
// See https://github.com/pypa/packaging-problems/issues/379
|
||
|
// See https://bugs.python.org/issue41327
|
||
|
if len(words) < 2 {
|
||
|
log.Debugf(ctx, "%s --version: stub from Windows Store", resolved)
|
||
|
return ""
|
||
|
}
|
||
|
|
||
|
if words[0] != "Python" {
|
||
|
log.Debugf(ctx, "%s --version: not a Python", resolved)
|
||
|
return ""
|
||
|
}
|
||
|
|
||
|
lastWord := words[len(words)-1]
|
||
|
version := semver.Canonical("v" + lastWord)
|
||
|
if version == "" {
|
||
|
log.Debugf(ctx, "%s --version: invalid SemVer: %s", resolved, lastWord)
|
||
|
return ""
|
||
|
}
|
||
|
|
||
|
return version
|
||
|
}
|