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 }