diff --git a/.nextchanges/cli/setup-local-installed-python-fallback.md b/.nextchanges/cli/setup-local-installed-python-fallback.md new file mode 100644 index 00000000000..44a53db68ee --- /dev/null +++ b/.nextchanges/cli/setup-local-installed-python-fallback.md @@ -0,0 +1 @@ +When `uv python install` fails, `databricks environments setup-local` now falls back to a compatible Python interpreter already installed on the machine. diff --git a/cmd/environments/output.go b/cmd/environments/output.go index aa2d124f371..490fec152ee 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -61,6 +61,9 @@ func renderResult(ctx context.Context, cmd *cobra.Command, res *libslocalenv.Res // unrelated output rather than part of the reason. cmdio.LogString(ctx, indent(res.Error.Error(), " ")) } + // Only on an actual failure, not a user interrupt: after Ctrl-C the + // fallback note reads as a contradictory postscript to "Setup canceled". + renderInstalledPythonFallback(ctx, res) } cmdio.LogString(ctx, "") cmdio.LogString(ctx, "Re-run with --debug for details, or --output json for a structured report.") @@ -111,6 +114,7 @@ func renderSuccess(ctx context.Context, res *libslocalenv.Result) { pyprojectDetail = "updated (backup: " + res.BackupPath + ")" } cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "pyproject.toml", pyprojectDetail)) + renderInstalledPythonFallback(ctx, res) cmdio.LogString(ctx, "") cmdio.LogString(ctx, "Next steps:") @@ -118,6 +122,15 @@ func renderSuccess(ctx context.Context, res *libslocalenv.Result) { cmdio.LogString(ctx, " • Or select "+res.VenvPath+" as the Python interpreter in VS Code / Cursor") } +// renderInstalledPythonFallback explains the non-default resolution path on +// both success and failure, so a later provisioning error is diagnosable. +func renderInstalledPythonFallback(ctx context.Context, res *libslocalenv.Result) { + if res.PythonResolution == libslocalenv.PythonResolutionInstalledFallback { + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, "Python download failed; using the installed interpreter "+res.PythonInterpreter+" instead.") + } +} + // activateHint returns the shell command to activate the virtual environment, // matching the running OS. uv lays the venv out as Scripts\activate on Windows // and bin/activate on Unix (see venvPython in libs/localenv/uv.go, which branches diff --git a/cmd/environments/output_test.go b/cmd/environments/output_test.go index bf8e5bf4694..e9df52b4bb3 100644 --- a/cmd/environments/output_test.go +++ b/cmd/environments/output_test.go @@ -49,6 +49,21 @@ func TestRenderSuccessSummary(t *testing.T) { assert.NotContains(t, out, "preflight ok") } +func TestRenderSuccessExplainsInstalledPythonFallback(t *testing.T) { + res := libslocalenv.NewResult() + res.OK = true + res.Resolved = &libslocalenv.ResolvedInfo{PythonVersion: "3.12"} + res.VenvPath = ".venv" + res.PythonResolution = libslocalenv.PythonResolutionInstalledFallback + res.PythonInterpreter = "/usr/bin/python3.12" + + out := renderText(t, res, nil) + + assert.Contains(t, out, "Python download failed") + // The exact interpreter is named so the user knows what backs the venv. + assert.Contains(t, out, "/usr/bin/python3.12") +} + func TestRenderSuccessConstraintsOnlyOmitsDBConnect(t *testing.T) { res := libslocalenv.NewResult() res.OK = true @@ -80,6 +95,36 @@ func TestRenderFailure(t *testing.T) { assert.Contains(t, out, "--debug") } +func TestRenderFailureExplainsInstalledPythonFallback(t *testing.T) { + res := libslocalenv.NewResult() + res.PythonResolution = libslocalenv.PythonResolutionInstalledFallback + res.PythonInterpreter = "/usr/bin/python3.12" + res.Error = &libslocalenv.PipelineError{ + Code: libslocalenv.ErrProvision, + FailurePhase: libslocalenv.PhaseProvision, + Msg: "sync failed", + } + + out := renderText(t, res, res.Error) + + assert.Contains(t, out, "Python download failed") + assert.Contains(t, out, "/usr/bin/python3.12") +} + +// A user interrupt after a fallback must not append the fallback note, which +// would contradict "Setup canceled". +func TestRenderCanceledOmitsInstalledPythonFallback(t *testing.T) { + res := libslocalenv.NewResult() + res.PythonResolution = libslocalenv.PythonResolutionInstalledFallback + res.PythonInterpreter = "/usr/bin/python3.12" + res.Error = &libslocalenv.PipelineError{Code: libslocalenv.ErrCanceled, FailurePhase: libslocalenv.PhaseProvision, Msg: "interrupted"} + + out := renderText(t, res, res.Error) + + assert.Contains(t, out, "canceled") + assert.NotContains(t, out, "Python download failed") +} + func TestRenderCanceled(t *testing.T) { res := libslocalenv.NewResult() res.Error = &libslocalenv.PipelineError{Code: libslocalenv.ErrCanceled, FailurePhase: libslocalenv.PhaseProvision, Msg: "interrupted"} diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 258645aba30..192041767b9 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -493,10 +493,20 @@ func (p *Pipeline) applyMerge(_ context.Context, mergedBytes []byte, greenfield // provision ensures the required Python version is installed, runs uv sync, and // seeds pip. All three are reported under the provision phase. func (p *Pipeline) provision(ctx context.Context, pyMinor string) error { - if err := p.PM.EnsurePython(ctx, pyMinor); err != nil { + selection, err := p.PM.EnsurePython(ctx, pyMinor) + if err != nil { return p.fail(PhaseProvision, true, asPipelineError(err, ErrPythonInstall, "ensure python %s failed", pyMinor)) } - if err := p.PM.Provision(ctx, p.ProjectDir, pyMinor); err != nil { + // Record how Python was resolved before sync: if provisioning fails after an + // installed fallback was selected, IDE consumers still need that categorical + // fact to offer the correct manual recovery without receiving the path. + p.res.PythonResolution = selection.Resolution + if selection.Resolution == PythonResolutionInstalledFallback { + // Only the fallback yields a concrete interpreter path; the normal path's + // Executable is just the minor request. The text summary names it (json:"-"). + p.res.PythonInterpreter = selection.Executable + } + if err := p.PM.Provision(ctx, p.ProjectDir, selection.Executable); err != nil { return p.fail(PhaseProvision, true, asPipelineError(err, ErrProvision, "provision failed")) } if err := p.PM.PostProvision(ctx, p.ProjectDir); err != nil { diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index fdf11c2322c..5cd97723b68 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -31,7 +31,9 @@ type fakePM struct{ py, dbc, pyspark, dbcImportErr string } func (fakePM) Name() string { return "fake" } func (fakePM) EnsureAvailable(context.Context) (string, error) { return "fake 1.0", nil } -func (fakePM) EnsurePython(context.Context, string) error { return nil } +func (fakePM) EnsurePython(_ context.Context, minor string) (PythonSelection, error) { + return PythonSelection{Executable: minor, Resolution: PythonResolutionUVInstallSucceeded}, nil +} func (fakePM) Provision(context.Context, string, string) error { return nil } func (fakePM) PostProvision(context.Context, string) error { return nil } func (f fakePM) Validate(context.Context, string) (VenvInfo, error) { @@ -48,8 +50,8 @@ func (noProvisionPM) EnsureAvailable(context.Context) (string, error) { return "", errors.New("EnsureAvailable must not be called under --dry-run") } -func (noProvisionPM) EnsurePython(context.Context, string) error { - return errors.New("EnsurePython must not be called under --dry-run") +func (noProvisionPM) EnsurePython(context.Context, string) (PythonSelection, error) { + return PythonSelection{}, errors.New("EnsurePython must not be called under --dry-run") } func (noProvisionPM) Provision(context.Context, string, string) error { @@ -73,6 +75,26 @@ func (uvMissingPM) EnsureAvailable(context.Context) (string, error) { return "", errors.New("uv not found and install failed") } +type recordingPM struct { + fakePM + minor string + provisionPython string + provisionErr error +} + +func (p *recordingPM) EnsurePython(_ context.Context, minor string) (PythonSelection, error) { + p.minor = minor + return PythonSelection{ + Executable: "/installed/python3.12", + Resolution: PythonResolutionInstalledFallback, + }, nil +} + +func (p *recordingPM) Provision(_ context.Context, _, python string) error { + p.provisionPython = python + return p.provisionErr +} + // cancelPM simulates uv being interrupted: Provision closes entered (so the test // knows the pipeline reached this phase), blocks until the context is cancelled, // then returns a *process.ProcessError carrying uv's stderr (NOT context.Canceled), @@ -109,6 +131,48 @@ dev = ["databricks-connect~=16.0.0"] return dir } +func TestPipelineProvisionsWithSelectedPython(t *testing.T) { + dir := writeProject(t) + srv := newTestServer(t) + defer srv.Close() + pm := &recordingPM{fakePM: fakePM{py: "3.12", dbc: "17.2.0"}} + p := &Pipeline{ + Mode: ModeDefault, ProjectDir: dir, + ConstraintBaseURL: srv.URL, CacheDir: t.TempDir(), + Flags: ComputeFlags{Serverless: "v4"}, Compute: stubCompute{}, PM: pm, + } + + res, err := p.Run(t.Context()) + + require.NoError(t, err) + assert.Equal(t, "3.12", pm.minor) + assert.Equal(t, "/installed/python3.12", pm.provisionPython) + assert.Equal(t, PythonResolutionInstalledFallback, res.PythonResolution) + assert.Equal(t, "/installed/python3.12", res.PythonInterpreter) +} + +func TestPipelineRetainsFallbackResolutionWhenProvisioningFails(t *testing.T) { + dir := writeProject(t) + srv := newTestServer(t) + defer srv.Close() + pm := &recordingPM{ + fakePM: fakePM{py: "3.12", dbc: "17.2.0"}, + provisionErr: errors.New("sync failed"), + } + p := &Pipeline{ + Mode: ModeDefault, ProjectDir: dir, + ConstraintBaseURL: srv.URL, CacheDir: t.TempDir(), + Flags: ComputeFlags{Serverless: "v4"}, Compute: stubCompute{}, PM: pm, + } + + res, err := p.Run(t.Context()) + + require.Error(t, err) + assert.Equal(t, PythonResolutionInstalledFallback, res.PythonResolution) + require.NotNil(t, res.Error) + assert.Equal(t, ErrProvision, res.Error.Code) +} + func newTestServer(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(sampleToml)) diff --git a/libs/localenv/pkgmanager.go b/libs/localenv/pkgmanager.go index 91cc136b37f..774bc11e569 100644 --- a/libs/localenv/pkgmanager.go +++ b/libs/localenv/pkgmanager.go @@ -11,14 +11,15 @@ type PackageManager interface { // it if necessary. It returns the version string on success. EnsureAvailable(ctx context.Context) (version string, err error) - // EnsurePython ensures the requested Python minor version (e.g. "3.12") is - // available via the package manager. - EnsurePython(ctx context.Context, minor string) error + // EnsurePython first asks the package manager to install the requested minor. + // If that fails, it may select an already-installed interpreter for that + // minor. The returned executable is passed unchanged to Provision. + EnsurePython(ctx context.Context, minor string) (PythonSelection, error) // Provision installs the project dependencies inside projectDir, pinning the - // environment to the given Python minor (e.g. "3.12") so it matches the target - // rather than whatever newer interpreter the manager might otherwise pick. - Provision(ctx context.Context, projectDir, pyMinor string) error + // environment to python, which is either a minor request (e.g. "3.12") or an + // exact interpreter path selected by EnsurePython. + Provision(ctx context.Context, projectDir, python string) error // PostProvision seeds pip into the virtual environment inside projectDir. // This step is required because VS Code's ms-python.vscode-python-envs @@ -33,6 +34,23 @@ type PackageManager interface { Validate(ctx context.Context, projectDir string) (VenvInfo, error) } +// PythonResolution records which preparation path supplied the interpreter. +// It is deliberately categorical (never a path or version) so consumers can +// branch on a stable value in the structured result. +type PythonResolution string + +const ( + PythonResolutionUnspecified PythonResolution = "" + PythonResolutionUVInstallSucceeded PythonResolution = "uv_install_succeeded" + PythonResolutionInstalledFallback PythonResolution = "installed_fallback" +) + +// PythonSelection is the interpreter uv sync must use and its resolution path. +type PythonSelection struct { + Executable string + Resolution PythonResolution +} + // VenvInfo is what the validate phase observed in the provisioned virtual environment. type VenvInfo struct { // PythonMinor is the interpreter's "major.minor" (e.g. "3.12"). diff --git a/libs/localenv/result.go b/libs/localenv/result.go index 236d845d685..2f44f167817 100644 --- a/libs/localenv/result.go +++ b/libs/localenv/result.go @@ -302,20 +302,26 @@ const ( // a distinction that trips JSON consumers and golden diffs. Construct a Result // with NewResult (or otherwise seed both) rather than a bare Result{} literal. type Result struct { - SchemaVersion int `json:"schemaVersion"` - Command string `json:"command"` - OK bool `json:"ok"` - Mode string `json:"mode"` - DryRun bool `json:"dryRun"` - Compute *ComputeInfo `json:"compute,omitempty"` - Resolved *ResolvedInfo `json:"resolved,omitempty"` - Greenfield bool `json:"greenfield"` - Plan *Plan `json:"plan,omitempty"` - VenvPath string `json:"venvPath,omitempty"` - Phases []PhaseStatus `json:"phases"` - Warnings []Warning `json:"warnings"` - Error *PipelineError `json:"error"` - BackupPath string `json:"backupPath,omitempty"` + SchemaVersion int `json:"schemaVersion"` + Command string `json:"command"` + OK bool `json:"ok"` + Mode string `json:"mode"` + DryRun bool `json:"dryRun"` + Compute *ComputeInfo `json:"compute,omitempty"` + Resolved *ResolvedInfo `json:"resolved,omitempty"` + Greenfield bool `json:"greenfield"` + Plan *Plan `json:"plan,omitempty"` + VenvPath string `json:"venvPath,omitempty"` + PythonResolution PythonResolution `json:"pythonResolution,omitempty"` + // PythonInterpreter is the exact interpreter chosen by the installed-Python + // fallback, named in the text summary so the user can tell which interpreter + // backs the venv. Not serialized: the structured result stays categorical via + // PythonResolution (a path would leak machine layout to JSON consumers). + PythonInterpreter string `json:"-"` + Phases []PhaseStatus `json:"phases"` + Warnings []Warning `json:"warnings"` + Error *PipelineError `json:"error"` + BackupPath string `json:"backupPath,omitempty"` // DurationMs is the pipeline's wall time in milliseconds (spec §6). It covers the // CLI pipeline only; the extension measures its own end-to-end latency (process // spawn, interpreter adoption) separately. diff --git a/libs/localenv/result_test.go b/libs/localenv/result_test.go index db75a0ebf22..1f3f6aa6b8d 100644 --- a/libs/localenv/result_test.go +++ b/libs/localenv/result_test.go @@ -44,6 +44,32 @@ func TestNewResultEmitsEmptyArraysNotNull(t *testing.T) { assert.Contains(t, string(bare), `"phases":null`, "sanity: bare literal is the null case") } +func TestResultEmitsPythonResolutionCategorically(t *testing.T) { + for _, resolution := range []PythonResolution{ + PythonResolutionUVInstallSucceeded, + PythonResolutionInstalledFallback, + } { + t.Run(string(resolution), func(t *testing.T) { + result := NewResult() + result.PythonResolution = resolution + + b, err := json.Marshal(result) + + require.NoError(t, err) + assert.Contains(t, string(b), `"pythonResolution":"`+string(resolution)+`"`) + assert.NotContains(t, string(b), "python3.12") + }) + } +} + +func TestResultOmitsUnknownPythonResolution(t *testing.T) { + result := NewResult() + result.PythonResolution = PythonResolutionUnspecified + b, err := json.Marshal(result) + require.NoError(t, err) + assert.NotContains(t, string(b), "pythonResolution") +} + func TestComputeInfoLabel(t *testing.T) { cases := []struct { name string diff --git a/libs/localenv/uv.go b/libs/localenv/uv.go index 7e4ca82c746..8cc5fbfbd38 100644 --- a/libs/localenv/uv.go +++ b/libs/localenv/uv.go @@ -79,30 +79,80 @@ func (m *uvManager) EnsureAvailable(ctx context.Context) (string, error) { // (Python, build backends); on SIGINT/SIGTERM they must be reaped as a group // rather than left as orphans holding locks over a half-written .venv. func (m *uvManager) runUv(ctx context.Context, args []string, dir string) error { + _, err := m.runUvOutput(ctx, args, dir) + return err +} + +// runUvOutput runs uv with the same directory, index-url bridge, and process +// group behavior as runUv, returning stdout for commands with structured output. +func (m *uvManager) runUvOutput(ctx context.Context, args []string, dir string) (string, error) { if indexURL := m.resolveIndexURL(ctx); indexURL != "" { - _, err := process.Background(ctx, args, process.WithDir(dir), process.WithEnv("UV_INDEX_URL", indexURL), process.WithProcessGroup()) - return err + return process.Background(ctx, args, process.WithDir(dir), process.WithEnv("UV_INDEX_URL", indexURL), process.WithProcessGroup()) } - _, err := process.Background(ctx, args, process.WithDir(dir), process.WithProcessGroup()) - return err + return process.Background(ctx, args, process.WithDir(dir), process.WithProcessGroup()) } -// EnsurePython installs the requested Python minor version via uv. -func (m *uvManager) EnsurePython(ctx context.Context, minor string) error { +// EnsurePython installs the requested Python minor via uv, falling back to a +// compatible interpreter already on the machine when the download fails. +func (m *uvManager) EnsurePython(ctx context.Context, minor string) (PythonSelection, error) { args := append([]string{m.bin}, m.pythonInstallArgs(minor)...) - if err := m.runUv(ctx, args, ""); err != nil { - return uvFailure(ErrPythonInstall, err, "uv python install "+minor) + installErr := m.runUv(ctx, args, "") + if installErr == nil { + return PythonSelection{Executable: minor, Resolution: PythonResolutionUVInstallSucceeded}, nil } - return nil + if ctx.Err() != nil { + return PythonSelection{}, uvFailure(ErrPythonInstall, installErr, "uv python install "+minor) + } + executable, findErr := m.findInstalledPython(ctx, minor) + if ctx.Err() != nil { + // Interrupted mid-search: report the cancellation, not a false "nothing + // found". errors.Join drops a nil findErr when the search had finished. + return PythonSelection{}, uvFailure(ErrPythonInstall, errors.Join(installErr, findErr), "uv python install "+minor) + } + if findErr != nil { + // The download and the installed-interpreter search are distinct commands. + // Attribute each stderr to its own clause ("found either: ") so + // the find stderr doesn't read as if `python install` rejected an argument it + // never saw. Kept on one line: PipelineError.Error() appends the wrapped + // cause after Msg, so a newline here would strand that tail on the next line. + // Both process errors ride along in the joined cause for errors.As / --debug. + msg := "uv python install " + minor + " failed" + if stderr := uvStderr(installErr); stderr != "" { + msg += ": " + stderr + } + msg += "; no compatible installed Python found either" + if stderr := uvStderr(findErr); stderr != "" { + msg += ": " + stderr + } + return PythonSelection{}, NewError(ErrPythonInstall, errors.Join(installErr, findErr), "%s", msg) + } + log.Debugf(ctx, "uv: Python download failed (%s); using installed interpreter %s", uvStderr(installErr), executable) + return PythonSelection{Executable: executable, Resolution: PythonResolutionInstalledFallback}, nil +} + +// findInstalledPython returns the path to an interpreter for the target minor +// already present on the machine, or an error if none is. uv applies its own +// managed-first preference and excludes project venvs; --system is required so an +// active .venv is ignored, and --no-python-downloads keeps it from re-attempting +// the download that just failed. +// https://docs.astral.sh/uv/reference/cli/#uv-python-find +func (m *uvManager) findInstalledPython(ctx context.Context, minor string) (string, error) { + args := append([]string{m.bin}, m.pythonFindArgs(minor)...) + out, err := m.runUvOutput(ctx, args, "") + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil } // Provision runs `uv sync` inside projectDir to install project dependencies, -// pinning the interpreter to pyMinor. Without --python, `uv sync` selects the +// pinning the interpreter to python. Without --python, `uv sync` selects the // newest installed interpreter satisfying requires-python (e.g. 3.13 for a -// ">=3.12" floor), which then fails validation against the 3.12 target; pinning -// the minor we just installed keeps the venv on the intended version. -func (m *uvManager) Provision(ctx context.Context, projectDir, pyMinor string) error { - args := append([]string{m.bin}, m.syncArgs(pyMinor)...) +// ">=3.12" floor), which then fails pipeline validation against the 3.12 target. +// The explicit request is either the minor just installed or the interpreter +// path found by the installed-Python fallback. +func (m *uvManager) Provision(ctx context.Context, projectDir, python string) error { + args := append([]string{m.bin}, m.syncArgs(python)...) if err := m.runUv(ctx, args, projectDir); err != nil { return uvFailure(ErrProvision, err, "uv sync") } @@ -216,9 +266,9 @@ func lineWithPrefix(out, prefix string) (string, bool) { } // syncArgs returns the argument slice for `uv sync` (without the binary), -// pinning the interpreter to pyMinor via --python. -func (m *uvManager) syncArgs(pyMinor string) []string { - return []string{"sync", "--python", pyMinor} +// pinning the interpreter to python via --python. +func (m *uvManager) syncArgs(python string) []string { + return []string{"sync", "--python", python} } // pythonInstallArgs returns the argument slice for `uv python install `. @@ -226,6 +276,13 @@ func (m *uvManager) pythonInstallArgs(minor string) []string { return []string{"python", "install", minor} } +// pythonFindArgs returns the arguments for locating an installed interpreter for +// the target minor without downloading. --system ignores the project's own venv. +// https://docs.astral.sh/uv/reference/cli/#uv-python-find +func (m *uvManager) pythonFindArgs(minor string) []string { + return []string{"python", "find", "--system", "--no-python-downloads", "cpython@==" + minor + ".*"} +} + // pipSeedArgs returns the argument slice for seeding pip into the venv. func (m *uvManager) pipSeedArgs(venvPython string) []string { return []string{"pip", "install", "pip", "--python", venvPython} @@ -338,12 +395,21 @@ func redactURLCredentials(raw string) string { // "Connection refused") rather than just the exit code. func uvFailure(code ErrorCode, err error, action string) *PipelineError { msg := action + " failed" - if perr, ok := errors.AsType[*process.ProcessError](err); ok && strings.TrimSpace(perr.Stderr) != "" { - msg = msg + ": " + strings.TrimSpace(perr.Stderr) + if stderr := uvStderr(err); stderr != "" { + msg = msg + ": " + stderr } return NewError(code, err, "%s", msg) } +// uvStderr returns the trimmed stderr of a failed uv process, or "" when err is +// not a process failure. +func uvStderr(err error) string { + if perr, ok := errors.AsType[*process.ProcessError](err); ok { + return strings.TrimSpace(perr.Stderr) + } + return "" +} + // confirmUvInstall reports whether the caller has consented to installUv running // a remote installer that mutates the machine. The EnvAutoInstallUv opt-in wins // outright (for CI / IDE integrations); otherwise an interactive session is diff --git a/libs/localenv/uv_test.go b/libs/localenv/uv_test.go index 8fcce4376a7..d1d8633eaa9 100644 --- a/libs/localenv/uv_test.go +++ b/libs/localenv/uv_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "os/exec" "path/filepath" "runtime" "testing" @@ -15,6 +16,8 @@ import ( "github.com/stretchr/testify/require" ) +const uvPythonInstall312Pattern = `^uv(?:\.exe)? python install 3\.12$` + // writePipConf writes conf to the primary OS-specific pip config path under a // fresh temp home and returns a context rooted at that home. On Windows the // primary path is %APPDATA%\pip\pip.ini, so APPDATA is pointed inside the temp @@ -35,13 +38,156 @@ func writePipConf(t *testing.T, conf string) context.Context { return ctx } +// uvStubCommand returns the normalized command spelling produced by +// processStub.Commands on the current platform. +func uvStubCommand(args string) string { + bin := "uv" + if runtime.GOOS == "windows" { + bin = "uv.exe" + } + return bin + " " + args +} + func TestUvArgs(t *testing.T) { m := &uvManager{bin: "uv"} assert.Equal(t, []string{"sync", "--python", "3.12"}, m.syncArgs("3.12")) assert.Equal(t, []string{"python", "install", "3.12"}, m.pythonInstallArgs("3.12")) + assert.Equal(t, []string{ + "python", "find", "--system", "--no-python-downloads", "cpython@==3.12.*", + }, m.pythonFindArgs("3.12")) assert.Equal(t, []string{"pip", "install", "pip", "--python", "/p/.venv/bin/python"}, m.pipSeedArgs("/p/.venv/bin/python")) } +func TestEnsurePythonStopsAfterSuccessfulInstall(t *testing.T) { + ctx, stub := process.WithStub(t.Context()) + stub.WithCallback(func(_ *exec.Cmd) error { return nil }) + m := &uvManager{bin: "uv"} + + selection, err := m.EnsurePython(ctx, "3.12") + + require.NoError(t, err) + assert.Equal(t, PythonSelection{ + Executable: "3.12", + Resolution: PythonResolutionUVInstallSucceeded, + }, selection) + assert.Equal(t, []string{uvStubCommand("python install 3.12")}, stub.Commands()) +} + +func TestEnsurePythonFallsBackToInstalledInterpreter(t *testing.T) { + ctx, stub := process.WithStub(t.Context()) + stub.WithFailureFor(uvPythonInstall312Pattern, errors.New("download blocked")) + stub.WithStdoutFor(`python find`, "/system/python3.12\n") + m := &uvManager{bin: "uv"} + + selection, err := m.EnsurePython(ctx, "3.12") + + require.NoError(t, err) + assert.Equal(t, PythonSelection{ + Executable: "/system/python3.12", + Resolution: PythonResolutionInstalledFallback, + }, selection) + assert.Equal(t, []string{ + uvStubCommand("python install 3.12"), + uvStubCommand("python find --system --no-python-downloads cpython@==3.12.*"), + }, stub.Commands()) +} + +func TestEnsurePythonFallbackPreservesIndexURLBridge(t *testing.T) { + // CI sets UV_INDEX_URL. Remove it so this test exercises pip.conf bridging; + // t.Setenv registers cleanup that restores the original value. + t.Setenv("UV_INDEX_URL", "") + os.Unsetenv("UV_INDEX_URL") + ctx := writePipConf(t, "[global]\nindex-url = https://proxy.example/simple\n") + ctx, stub := process.WithStub(ctx) + stub.WithFailureFor(uvPythonInstall312Pattern, errors.New("download blocked")) + stub.WithStdoutFor(`python find`, "/system/python3.12\n") + + selection, err := (&uvManager{bin: "uv"}).EnsurePython(ctx, "3.12") + + require.NoError(t, err) + assert.Equal(t, "/system/python3.12", selection.Executable) + assert.Equal(t, "https://proxy.example/simple", stub.LookupEnv("UV_INDEX_URL")) +} + +func TestEnsurePythonStopsFallbackWhenContextIsCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + ctx, stub := process.WithStub(ctx) + stub.WithCallback(func(_ *exec.Cmd) error { + cancel() + return context.Canceled + }) + + _, err := (&uvManager{bin: "uv"}).EnsurePython(ctx, "3.12") + + require.ErrorIs(t, err, context.Canceled) + assert.Equal(t, []string{uvStubCommand("python install 3.12")}, stub.Commands()) +} + +func TestEnsurePythonFallbackErrorSurfacesSearchStderr(t *testing.T) { + ctx, stub := process.WithStub(t.Context()) + stub.WithStderrFor(uvPythonInstall312Pattern, "error: Connection refused") + stub.WithFailureFor(uvPythonInstall312Pattern, errors.New("exit status 1")) + stub.WithStderrFor(`python find`, "error: unexpected argument '--no-python-downloads'") + stub.WithFailureFor(`python find`, errors.New("exit status 2")) + m := &uvManager{bin: "uv"} + + _, err := m.EnsurePython(ctx, "3.12") + + require.Error(t, err) + var pe *PipelineError + require.ErrorAs(t, err, &pe) + // Each stderr must be attributed to its own command so the find stderr does + // not read as if `python install` rejected an argument it never saw. Assert on + // Error() — what the user sees — since PipelineError.Error() appends the wrapped + // cause after Msg. + rendered := pe.Error() + assert.Contains(t, rendered, "uv python install 3.12 failed: error: Connection refused") + assert.Contains(t, rendered, "no compatible installed Python found either: error: unexpected argument '--no-python-downloads'") +} + +func TestEnsurePythonReportsCancellationDuringFallbackSearch(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + ctx, stub := process.WithStub(ctx) + // Install fails normally (ctx still live), so the search runs; the search is + // then interrupted. The error must report the interruption, not claim that no + // interpreter is installed. + stub.WithFailureFor(uvPythonInstall312Pattern, errors.New("download blocked")) + stub.WithCallback(func(_ *exec.Cmd) error { + cancel() + return context.Canceled + }) + + _, err := (&uvManager{bin: "uv"}).EnsurePython(ctx, "3.12") + + require.ErrorIs(t, err, context.Canceled) + var pe *PipelineError + require.ErrorAs(t, err, &pe) + assert.NotContains(t, pe.Msg, "no compatible installed Python found") + assert.Equal(t, []string{ + uvStubCommand("python install 3.12"), + uvStubCommand("python find --system --no-python-downloads cpython@==3.12.*"), + }, stub.Commands()) +} + +func TestEnsurePythonFailsWhenFallbackFindsNothing(t *testing.T) { + ctx, stub := process.WithStub(t.Context()) + stub.WithFailureFor(uvPythonInstall312Pattern, errors.New("download blocked")) + stub.WithFailureFor(`python find`, errors.New("No interpreter found")) + m := &uvManager{bin: "uv"} + + selection, err := m.EnsurePython(ctx, "3.12") + + require.Error(t, err) + assert.Empty(t, selection) + assert.Equal(t, []string{ + uvStubCommand("python install 3.12"), + uvStubCommand("python find --system --no-python-downloads cpython@==3.12.*"), + }, stub.Commands()) + var pe *PipelineError + require.ErrorAs(t, err, &pe) + assert.Equal(t, ErrPythonInstall, pe.Code) +} + func TestVenvPythonPath(t *testing.T) { // Validate invokes this interpreter directly (not via `uv run`) so it observes // exactly the .venv that was provisioned, ignoring any active VIRTUAL_ENV.