From 2b9d370cddadb9ad443265723957f702c4d41343 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Tue, 1 Sep 2026 11:03:00 +0200 Subject: [PATCH 1/6] localenv: fall back to installed Python --- cmd/environments/output.go | 4 + cmd/environments/output_test.go | 13 +++ libs/localenv/pipeline.go | 13 ++- libs/localenv/pipeline_test.go | 72 +++++++++++++++- libs/localenv/pkgmanager.go | 29 +++++-- libs/localenv/result.go | 29 +++---- libs/localenv/result_test.go | 17 ++++ libs/localenv/uv.go | 140 ++++++++++++++++++++++++++++---- libs/localenv/uv_test.go | 124 ++++++++++++++++++++++++++++ 9 files changed, 397 insertions(+), 44 deletions(-) diff --git a/cmd/environments/output.go b/cmd/environments/output.go index aa2d124f371..fecc2d1692d 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -111,6 +111,10 @@ func renderSuccess(ctx context.Context, res *libslocalenv.Result) { pyprojectDetail = "updated (backup: " + res.BackupPath + ")" } cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "pyproject.toml", pyprojectDetail)) + if res.PythonResolution == libslocalenv.PythonResolutionInstalledFallback { + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, "Python download failed; used a compatible installed Python instead.") + } cmdio.LogString(ctx, "") cmdio.LogString(ctx, "Next steps:") diff --git a/cmd/environments/output_test.go b/cmd/environments/output_test.go index bf8e5bf4694..353162f7214 100644 --- a/cmd/environments/output_test.go +++ b/cmd/environments/output_test.go @@ -49,6 +49,19 @@ 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 + + out := renderText(t, res, nil) + + assert.Contains(t, out, "Python download failed") + assert.Contains(t, out, "compatible installed Python") +} + func TestRenderSuccessConstraintsOnlyOmitsDBConnect(t *testing.T) { res := libslocalenv.NewResult() res.OK = true diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 258645aba30..16477079c3e 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -247,7 +247,7 @@ func (p *Pipeline) run(ctx context.Context) error { // Phase: provision — ensure Python, run uv sync, seed pip. p.report(ctx, PhaseProvision) - if err := p.provision(ctx, pyMinor); err != nil { + if err := p.provision(ctx, pyMinor, c.RequiresPython); err != nil { return err } @@ -492,11 +492,16 @@ 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 { +func (p *Pipeline) provision(ctx context.Context, pyMinor, constraint string) error { + selection, err := p.PM.EnsurePython(ctx, pyMinor, constraint) + 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 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..c1cba4810c0 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, 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,28 @@ func (uvMissingPM) EnsureAvailable(context.Context) (string, error) { return "", errors.New("uv not found and install failed") } +type recordingPM struct { + fakePM + minor string + constraint string + provisionPython string + provisionErr error +} + +func (p *recordingPM) EnsurePython(_ context.Context, minor, constraint string) (PythonSelection, error) { + p.minor = minor + p.constraint = constraint + 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 +133,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, "==3.12.*", pm.constraint) + assert.Equal(t, "/installed/python3.12", pm.provisionPython) + assert.Equal(t, PythonResolutionInstalledFallback, res.PythonResolution) +} + +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..4432a6120fb 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 satisfying + // constraint. The returned executable is passed unchanged to Provision. + EnsurePython(ctx context.Context, minor, constraint 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,22 @@ type PackageManager interface { Validate(ctx context.Context, projectDir string) (VenvInfo, error) } +// PythonResolution records which preparation path supplied the interpreter. +// It is deliberately categorical: executable paths and versions never belong +// in telemetry derived from the structured result. +type PythonResolution string + +const ( + 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..0c0dd26fe94 100644 --- a/libs/localenv/result.go +++ b/libs/localenv/result.go @@ -302,20 +302,21 @@ 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"` + 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..3a2fb8d0fc0 100644 --- a/libs/localenv/result_test.go +++ b/libs/localenv/result_test.go @@ -44,6 +44,23 @@ func TestNewResultEmitsEmptyArraysNotNull(t *testing.T) { assert.Contains(t, string(bare), `"phases":null`, "sanity: bare literal is the null case") } +func TestResultEmitsPythonResolutionCategorically(t *testing.T) { + result := NewResult() + result.PythonResolution = PythonResolutionInstalledFallback + + b, err := json.Marshal(result) + + require.NoError(t, err) + assert.Contains(t, string(b), `"pythonResolution":"installed_fallback"`) + assert.NotContains(t, string(b), "python3.12") +} + +func TestResultOmitsUnknownPythonResolution(t *testing.T) { + b, err := json.Marshal(NewResult()) + 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..10b802d9a08 100644 --- a/libs/localenv/uv.go +++ b/libs/localenv/uv.go @@ -3,7 +3,9 @@ package localenv import ( "bufio" "context" + "encoding/json" "errors" + "fmt" "net/url" "os" "os/exec" @@ -26,7 +28,69 @@ const EnvAutoInstallUv = "DATABRICKS_LOCALENV_AUTO_INSTALL_UV" // uvManager implements PackageManager using the uv tool. // https://docs.astral.sh/uv/ type uvManager struct { - bin string + bin string + runFn uvRunFn +} + +type uvRunFn func(ctx context.Context, args []string, dir string) (string, error) + +type uvPython struct { + VersionParts struct { + Major int `json:"major"` + Minor int `json:"minor"` + Patch int `json:"patch"` + } `json:"version_parts"` + Path string `json:"path"` +} + +type installedPython struct { + uvPython + managed bool +} + +func selectInstalledPython(managedJSON, systemJSON []byte) (string, error) { + var managed, system []uvPython + if err := json.Unmarshal(managedJSON, &managed); err != nil { + return "", fmt.Errorf("parse managed Python installations: %w", err) + } + if err := json.Unmarshal(systemJSON, &system); err != nil { + return "", fmt.Errorf("parse system Python installations: %w", err) + } + + var best *installedPython + for _, group := range []struct { + pythons []uvPython + managed bool + }{{managed, true}, {system, false}} { + for _, python := range group.pythons { + candidate := installedPython{uvPython: python, managed: group.managed} + if python.Path == "" { + return "", errors.New("installed Python entry has no executable path") + } + if best == nil || newerInstalledPython(candidate, *best) { + best = &candidate + } + } + } + if best == nil { + return "", errors.New("no compatible installed Python found") + } + return best.Path, nil +} + +func newerInstalledPython(candidate, current installedPython) bool { + c := candidate.VersionParts + b := current.VersionParts + if c.Major != b.Major { + return c.Major > b.Major + } + if c.Minor != b.Minor { + return c.Minor > b.Minor + } + if c.Patch != b.Patch { + return c.Patch > b.Patch + } + return candidate.managed && !current.managed } // NewUvManager returns a PackageManager backed by the uv tool. The binary path @@ -79,30 +143,58 @@ 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 +} + +func (m *uvManager) runUvOutput(ctx context.Context, args []string, dir string) (string, error) { + if m.runFn != nil { + return m.runFn(ctx, args, dir) + } 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 { +func (m *uvManager) EnsurePython(ctx context.Context, minor, constraint 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 + + managed, err := m.listInstalledPython(ctx, constraint, true) + if err != nil { + return PythonSelection{}, uvFailure(ErrPythonInstall, errors.Join(installErr, err), "uv python install "+minor) + } + system, err := m.listInstalledPython(ctx, constraint, false) + if err != nil { + return PythonSelection{}, uvFailure(ErrPythonInstall, errors.Join(installErr, err), "uv python install "+minor) + } + executable, err := selectInstalledPython(managed, system) + if err != nil { + return PythonSelection{}, uvFailure(ErrPythonInstall, errors.Join(installErr, err), "uv python install "+minor) + } + return PythonSelection{Executable: executable, Resolution: PythonResolutionInstalledFallback}, nil +} + +func (m *uvManager) listInstalledPython(ctx context.Context, constraint string, managed bool) ([]byte, error) { + args := append([]string{m.bin}, m.pythonListArgs(constraint, managed)...) + out, err := m.runUvOutput(ctx, args, "") + if err != nil { + return nil, err + } + return []byte(out), nil } // Provision runs `uv sync` inside projectDir to install project dependencies, -// pinning the interpreter to pyMinor. 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)...) +// pinning the interpreter to python. Without --python, `uv sync` can select a +// newer interpreter than the target; the explicit request is either the minor +// just installed or the exact compatible path selected by fallback discovery. +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") } @@ -217,8 +309,8 @@ 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} +func (m *uvManager) syncArgs(python string) []string { + return []string{"sync", "--python", python} } // pythonInstallArgs returns the argument slice for `uv python install `. @@ -226,6 +318,20 @@ func (m *uvManager) pythonInstallArgs(minor string) []string { return []string{"python", "install", minor} } +// pythonListArgs returns the arguments for listing compatible interpreters +// already present on this machine. Splitting managed and system installations +// lets selection prefer a uv-managed interpreter only when versions tie. +func (m *uvManager) pythonListArgs(constraint string, managed bool) []string { + preference := "--no-managed-python" + if managed { + preference = "--managed-python" + } + return []string{ + "python", "list", "--only-installed", "--all-versions", + "--output-format", "json", preference, "cpython@" + constraint, + } +} + // 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} diff --git a/libs/localenv/uv_test.go b/libs/localenv/uv_test.go index 8fcce4376a7..820749ca2cb 100644 --- a/libs/localenv/uv_test.go +++ b/libs/localenv/uv_test.go @@ -39,9 +39,133 @@ 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", "list", "--only-installed", "--all-versions", + "--output-format", "json", "--managed-python", "cpython@==3.12.*", + }, m.pythonListArgs("==3.12.*", true)) + assert.Equal(t, []string{ + "python", "list", "--only-installed", "--all-versions", + "--output-format", "json", "--no-managed-python", "cpython@==3.12.*", + }, m.pythonListArgs("==3.12.*", false)) assert.Equal(t, []string{"pip", "install", "pip", "--python", "/p/.venv/bin/python"}, m.pipSeedArgs("/p/.venv/bin/python")) } +func TestSelectInstalledPython(t *testing.T) { + tests := []struct { + name string + managed string + system string + want string + wantErr bool + }{ + { + name: "newer system patch beats managed", + managed: `[{"version_parts":{"major":3,"minor":12,"patch":10},"path":"/managed/3.12.10"}]`, + system: `[{"version_parts":{"major":3,"minor":12,"patch":11},"path":"/system/3.12.11"}]`, + want: "/system/3.12.11", + }, + { + name: "managed wins an equal-version tie", + managed: `[{"version_parts":{"major":3,"minor":12,"patch":10},"path":"/managed/3.12.10"}]`, + system: `[{"version_parts":{"major":3,"minor":12,"patch":10},"path":"/system/3.12.10"}]`, + want: "/managed/3.12.10", + }, + { + name: "highest candidate within one source wins", + managed: `[{"version_parts":{"major":3,"minor":12,"patch":8},"path":"/managed/3.12.8"},{"version_parts":{"major":3,"minor":12,"patch":12},"path":"/managed/3.12.12"}]`, + system: `[]`, + want: "/managed/3.12.12", + }, + {name: "no candidates", managed: `[]`, system: `[]`, wantErr: true}, + {name: "malformed JSON", managed: `{`, system: `[]`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := selectInstalledPython([]byte(tt.managed), []byte(tt.system)) + if tt.wantErr { + require.Error(t, err) + assert.Empty(t, got) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestEnsurePythonStopsAfterSuccessfulInstall(t *testing.T) { + var calls [][]string + m := &uvManager{bin: "uv", runFn: func(_ context.Context, args []string, _ string) (string, error) { + calls = append(calls, args) + return "", nil + }} + + selection, err := m.EnsurePython(t.Context(), "3.12", "==3.12.*") + + require.NoError(t, err) + assert.Equal(t, PythonSelection{ + Executable: "3.12", + Resolution: PythonResolutionUVInstallSucceeded, + }, selection) + assert.Equal(t, [][]string{{"uv", "python", "install", "3.12"}}, calls) +} + +func TestEnsurePythonFallsBackToInstalledInterpreter(t *testing.T) { + var calls [][]string + m := &uvManager{bin: "uv", runFn: func(_ context.Context, args []string, _ string) (string, error) { + calls = append(calls, args) + switch len(calls) { + case 1: + return "", errors.New("download blocked") + case 2: + return `[{"version_parts":{"major":3,"minor":12,"patch":10},"path":"/managed/python3.12"}]`, nil + case 3: + return `[{"version_parts":{"major":3,"minor":12,"patch":11},"path":"/system/python3.12"}]`, nil + default: + t.Fatalf("unexpected extra uv call: %v", args) + return "", nil + } + }} + + selection, err := m.EnsurePython(t.Context(), "3.12", "==3.12.*") + + require.NoError(t, err) + assert.Equal(t, PythonSelection{ + Executable: "/system/python3.12", + Resolution: PythonResolutionInstalledFallback, + }, selection) + require.Len(t, calls, 3) + assert.Equal(t, []string{ + "uv", "python", "list", "--only-installed", "--all-versions", + "--output-format", "json", "--managed-python", "cpython@==3.12.*", + }, calls[1]) + assert.Equal(t, []string{ + "uv", "python", "list", "--only-installed", "--all-versions", + "--output-format", "json", "--no-managed-python", "cpython@==3.12.*", + }, calls[2]) +} + +func TestEnsurePythonDoesNotRetryWhenFallbackFails(t *testing.T) { + installCalls := 0 + m := &uvManager{bin: "uv", runFn: func(_ context.Context, args []string, _ string) (string, error) { + if len(args) >= 3 && args[1] == "python" && args[2] == "install" { + installCalls++ + return "", errors.New("download blocked") + } + return `[]`, nil + }} + + selection, err := m.EnsurePython(t.Context(), "3.12", "==3.12.*") + + require.Error(t, err) + assert.Empty(t, selection) + assert.Equal(t, 1, installCalls) + 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. From 7ccc6b4649ce56ac7131e4fa648dd330961b5aa5 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Tue, 1 Sep 2026 14:22:33 +0200 Subject: [PATCH 2/6] localenv: constrain fallback to target minor --- libs/localenv/uv.go | 5 +++-- libs/localenv/uv_test.go | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/libs/localenv/uv.go b/libs/localenv/uv.go index 10b802d9a08..ea728930c36 100644 --- a/libs/localenv/uv.go +++ b/libs/localenv/uv.go @@ -164,12 +164,13 @@ func (m *uvManager) EnsurePython(ctx context.Context, minor, constraint string) if installErr == nil { return PythonSelection{Executable: minor, Resolution: PythonResolutionUVInstallSucceeded}, nil } + fallbackConstraint := constraint + ",==" + minor + ".*" - managed, err := m.listInstalledPython(ctx, constraint, true) + managed, err := m.listInstalledPython(ctx, fallbackConstraint, true) if err != nil { return PythonSelection{}, uvFailure(ErrPythonInstall, errors.Join(installErr, err), "uv python install "+minor) } - system, err := m.listInstalledPython(ctx, constraint, false) + system, err := m.listInstalledPython(ctx, fallbackConstraint, false) if err != nil { return PythonSelection{}, uvFailure(ErrPythonInstall, errors.Join(installErr, err), "uv python install "+minor) } diff --git a/libs/localenv/uv_test.go b/libs/localenv/uv_test.go index 820749ca2cb..09b2a6eb3c7 100644 --- a/libs/localenv/uv_test.go +++ b/libs/localenv/uv_test.go @@ -128,7 +128,7 @@ func TestEnsurePythonFallsBackToInstalledInterpreter(t *testing.T) { } }} - selection, err := m.EnsurePython(t.Context(), "3.12", "==3.12.*") + selection, err := m.EnsurePython(t.Context(), "3.12", ">=3.12,<3.15") require.NoError(t, err) assert.Equal(t, PythonSelection{ @@ -138,11 +138,11 @@ func TestEnsurePythonFallsBackToInstalledInterpreter(t *testing.T) { require.Len(t, calls, 3) assert.Equal(t, []string{ "uv", "python", "list", "--only-installed", "--all-versions", - "--output-format", "json", "--managed-python", "cpython@==3.12.*", + "--output-format", "json", "--managed-python", "cpython@>=3.12,<3.15,==3.12.*", }, calls[1]) assert.Equal(t, []string{ "uv", "python", "list", "--only-installed", "--all-versions", - "--output-format", "json", "--no-managed-python", "cpython@==3.12.*", + "--output-format", "json", "--no-managed-python", "cpython@>=3.12,<3.15,==3.12.*", }, calls[2]) } From c2aaad66477780e2d8814254bc084007cc885503 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Tue, 1 Sep 2026 17:11:59 +0200 Subject: [PATCH 3/6] Fix installed Python fallback review findings --- .../setup-local-installed-python-fallback.md | 1 + cmd/environments/output.go | 15 +- cmd/environments/output_test.go | 15 ++ libs/localenv/pkgmanager.go | 1 + libs/localenv/result_test.go | 23 ++- libs/localenv/uv.go | 97 ++++++++++-- libs/localenv/uv_test.go | 140 +++++++++++++----- 7 files changed, 224 insertions(+), 68 deletions(-) create mode 100644 .nextchanges/cli/setup-local-installed-python-fallback.md 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 fecc2d1692d..f4e6695bb87 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -62,6 +62,7 @@ func renderResult(ctx context.Context, cmd *cobra.Command, res *libslocalenv.Res cmdio.LogString(ctx, indent(res.Error.Error(), " ")) } } + renderInstalledPythonFallback(ctx, res) cmdio.LogString(ctx, "") cmdio.LogString(ctx, "Re-run with --debug for details, or --output json for a structured report.") // The failing message is already surfaced above; ErrAlreadyPrinted exits @@ -111,10 +112,7 @@ func renderSuccess(ctx context.Context, res *libslocalenv.Result) { pyprojectDetail = "updated (backup: " + res.BackupPath + ")" } cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "pyproject.toml", pyprojectDetail)) - if res.PythonResolution == libslocalenv.PythonResolutionInstalledFallback { - cmdio.LogString(ctx, "") - cmdio.LogString(ctx, "Python download failed; used a compatible installed Python instead.") - } + renderInstalledPythonFallback(ctx, res) cmdio.LogString(ctx, "") cmdio.LogString(ctx, "Next steps:") @@ -122,6 +120,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; used a compatible installed Python 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 353162f7214..e7cd14be55e 100644 --- a/cmd/environments/output_test.go +++ b/cmd/environments/output_test.go @@ -93,6 +93,21 @@ func TestRenderFailure(t *testing.T) { assert.Contains(t, out, "--debug") } +func TestRenderFailureExplainsInstalledPythonFallback(t *testing.T) { + res := libslocalenv.NewResult() + res.PythonResolution = libslocalenv.PythonResolutionInstalledFallback + 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, "compatible installed Python") +} + 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/pkgmanager.go b/libs/localenv/pkgmanager.go index 4432a6120fb..9ba67ab773b 100644 --- a/libs/localenv/pkgmanager.go +++ b/libs/localenv/pkgmanager.go @@ -40,6 +40,7 @@ type PackageManager interface { type PythonResolution string const ( + PythonResolutionUnspecified PythonResolution = "" PythonResolutionUVInstallSucceeded PythonResolution = "uv_install_succeeded" PythonResolutionInstalledFallback PythonResolution = "installed_fallback" ) diff --git a/libs/localenv/result_test.go b/libs/localenv/result_test.go index 3a2fb8d0fc0..1f3f6aa6b8d 100644 --- a/libs/localenv/result_test.go +++ b/libs/localenv/result_test.go @@ -45,18 +45,27 @@ func TestNewResultEmitsEmptyArraysNotNull(t *testing.T) { } func TestResultEmitsPythonResolutionCategorically(t *testing.T) { - result := NewResult() - result.PythonResolution = PythonResolutionInstalledFallback + for _, resolution := range []PythonResolution{ + PythonResolutionUVInstallSucceeded, + PythonResolutionInstalledFallback, + } { + t.Run(string(resolution), func(t *testing.T) { + result := NewResult() + result.PythonResolution = resolution - b, err := json.Marshal(result) + b, err := json.Marshal(result) - require.NoError(t, err) - assert.Contains(t, string(b), `"pythonResolution":"installed_fallback"`) - assert.NotContains(t, string(b), "python3.12") + require.NoError(t, err) + assert.Contains(t, string(b), `"pythonResolution":"`+string(resolution)+`"`) + assert.NotContains(t, string(b), "python3.12") + }) + } } func TestResultOmitsUnknownPythonResolution(t *testing.T) { - b, err := json.Marshal(NewResult()) + result := NewResult() + result.PythonResolution = PythonResolutionUnspecified + b, err := json.Marshal(result) require.NoError(t, err) assert.NotContains(t, string(b), "pythonResolution") } diff --git a/libs/localenv/uv.go b/libs/localenv/uv.go index ea728930c36..c065596e184 100644 --- a/libs/localenv/uv.go +++ b/libs/localenv/uv.go @@ -28,12 +28,11 @@ const EnvAutoInstallUv = "DATABRICKS_LOCALENV_AUTO_INSTALL_UV" // uvManager implements PackageManager using the uv tool. // https://docs.astral.sh/uv/ type uvManager struct { - bin string - runFn uvRunFn + bin string } -type uvRunFn func(ctx context.Context, args []string, dir string) (string, error) - +// uvPython is the subset of uv's JSON-formatted Python installation record +// needed to choose an executable. type uvPython struct { VersionParts struct { Major int `json:"major"` @@ -43,11 +42,15 @@ type uvPython struct { Path string `json:"path"` } +// installedPython associates a uv installation record with its source so an +// equal-version tie can prefer a uv-managed interpreter. type installedPython struct { uvPython managed bool } +// selectInstalledPython returns the highest compatible patch from uv's managed +// and system installation lists. A managed interpreter wins an exact tie. func selectInstalledPython(managedJSON, systemJSON []byte) (string, error) { var managed, system []uvPython if err := json.Unmarshal(managedJSON, &managed); err != nil { @@ -61,11 +64,11 @@ func selectInstalledPython(managedJSON, systemJSON []byte) (string, error) { for _, group := range []struct { pythons []uvPython managed bool - }{{managed, true}, {system, false}} { + }{{system, false}, {managed, true}} { for _, python := range group.pythons { candidate := installedPython{uvPython: python, managed: group.managed} if python.Path == "" { - return "", errors.New("installed Python entry has no executable path") + continue } if best == nil || newerInstalledPython(candidate, *best) { best = &candidate @@ -78,6 +81,8 @@ func selectInstalledPython(managedJSON, systemJSON []byte) (string, error) { return best.Path, nil } +// newerInstalledPython reports whether candidate should replace current. Patch +// version is the primary product preference; uv-managed wins only an exact tie. func newerInstalledPython(candidate, current installedPython) bool { c := candidate.VersionParts b := current.VersionParts @@ -147,10 +152,9 @@ func (m *uvManager) runUv(ctx context.Context, args []string, dir string) error 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 m.runFn != nil { - return m.runFn(ctx, args, dir) - } if indexURL := m.resolveIndexURL(ctx); indexURL != "" { return process.Background(ctx, args, process.WithDir(dir), process.WithEnv("UV_INDEX_URL", indexURL), process.WithProcessGroup()) } @@ -164,7 +168,10 @@ func (m *uvManager) EnsurePython(ctx context.Context, minor, constraint string) if installErr == nil { return PythonSelection{Executable: minor, Resolution: PythonResolutionUVInstallSucceeded}, nil } - fallbackConstraint := constraint + ",==" + minor + ".*" + if ctx.Err() != nil { + return PythonSelection{}, uvFailure(ErrPythonInstall, installErr, "uv python install "+minor) + } + fallbackConstraint := fallbackPythonConstraint(constraint, minor) managed, err := m.listInstalledPython(ctx, fallbackConstraint, true) if err != nil { @@ -178,9 +185,37 @@ func (m *uvManager) EnsurePython(ctx context.Context, minor, constraint string) if err != nil { return PythonSelection{}, uvFailure(ErrPythonInstall, errors.Join(installErr, err), "uv python install "+minor) } + log.Debugf(ctx, "uv: selected compatible installed Python at %s", executable) return PythonSelection{Executable: executable, Resolution: PythonResolutionInstalledFallback}, nil } +// fallbackPythonConstraint converts the accepted shorthand forms of +// requires-python to specifiers uv can parse, then pins discovery to the target +// minor. See https://docs.astral.sh/uv/concepts/python-versions/#requesting-a-version +func fallbackPythonConstraint(constraint, minor string) string { + clauses := make([]string, 0, strings.Count(constraint, ",")+2) + minorPin := "==" + minor + ".*" + hasMinorPin := false + for clause := range strings.SplitSeq(constraint, ",") { + clause = strings.TrimSpace(clause) + if clause == "" { + continue + } + match := clauseRe.FindStringSubmatch(clause) + if match != nil && match[1] == "" { + clause = ">=" + clause + } + hasMinorPin = hasMinorPin || clause == minorPin + clauses = append(clauses, clause) + } + if !hasMinorPin { + clauses = append(clauses, minorPin) + } + return strings.Join(clauses, ",") +} + +// listInstalledPython asks uv for compatible installed interpreters from one +// source and returns its JSON response unchanged for selection. func (m *uvManager) listInstalledPython(ctx context.Context, constraint string, managed bool) ([]byte, error) { args := append([]string{m.bin}, m.pythonListArgs(constraint, managed)...) out, err := m.runUvOutput(ctx, args, "") @@ -191,9 +226,11 @@ func (m *uvManager) listInstalledPython(ctx context.Context, constraint string, } // Provision runs `uv sync` inside projectDir to install project dependencies, -// pinning the interpreter to python. Without --python, `uv sync` can select a -// newer interpreter than the target; the explicit request is either the minor -// just installed or the exact compatible path selected by fallback discovery. +// 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 pipeline validation against the 3.12 target. +// The explicit request is either the minor just installed or the exact +// compatible path selected by fallback discovery. 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 { @@ -309,7 +346,7 @@ 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. +// pinning the interpreter to python via --python. func (m *uvManager) syncArgs(python string) []string { return []string{"sync", "--python", python} } @@ -322,6 +359,7 @@ func (m *uvManager) pythonInstallArgs(minor string) []string { // pythonListArgs returns the arguments for listing compatible interpreters // already present on this machine. Splitting managed and system installations // lets selection prefer a uv-managed interpreter only when versions tie. +// https://docs.astral.sh/uv/reference/cli/#uv-python-list func (m *uvManager) pythonListArgs(constraint string, managed bool) []string { preference := "--no-managed-python" if managed { @@ -445,12 +483,39 @@ 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 := processStderr(err); stderr != "" { + msg = msg + ": " + stderr } return NewError(code, err, "%s", msg) } +// processStderr collects diagnostics from every process failure in an error +// tree, including errors.Join trees produced by fallback attempts. +func processStderr(err error) string { + return strings.Join(appendProcessStderr(nil, err), "\n") +} + +// appendProcessStderr recursively appends process diagnostics from err while +// preserving the order of errors.Join children. +func appendProcessStderr(messages []string, err error) []string { + if err == nil { + return messages + } + if joined, ok := err.(interface{ Unwrap() []error }); ok { + for _, child := range joined.Unwrap() { + messages = appendProcessStderr(messages, child) + } + return messages + } + if perr, ok := err.(*process.ProcessError); ok { + if stderr := strings.TrimSpace(perr.Stderr); stderr != "" { + messages = append(messages, stderr) + } + return messages + } + return appendProcessStderr(messages, errors.Unwrap(err)) +} + // 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 09b2a6eb3c7..33f77d95c31 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,6 +38,16 @@ 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")) @@ -76,6 +89,12 @@ func TestSelectInstalledPython(t *testing.T) { system: `[]`, want: "/managed/3.12.12", }, + { + name: "entry without a path is skipped", + managed: `[{"version_parts":{"major":3,"minor":12,"patch":12},"path":""},{"version_parts":{"major":3,"minor":12,"patch":10},"path":"/managed/3.12.10"}]`, + system: `[]`, + want: "/managed/3.12.10", + }, {name: "no candidates", managed: `[]`, system: `[]`, wantErr: true}, {name: "malformed JSON", managed: `{`, system: `[]`, wantErr: true}, } @@ -94,73 +113,104 @@ func TestSelectInstalledPython(t *testing.T) { } } +func TestFallbackPythonConstraint(t *testing.T) { + tests := []struct { + name string + constraint string + want string + }{ + {"trims clauses", " >=3.12 , <3.13 ", ">=3.12,<3.13,==3.12.*"}, + {"normalizes bare floor", "3.12", ">=3.12,==3.12.*"}, + {"normalizes bare floor among exclusions", "!=3.11,3.12", "!=3.11,>=3.12,==3.12.*"}, + {"preserves exact minor", "==3.12", "==3.12,==3.12.*"}, + {"preserves arbitrary equality", "===3.12", "===3.12,==3.12.*"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, fallbackPythonConstraint(tt.constraint, "3.12")) + }) + } +} + func TestEnsurePythonStopsAfterSuccessfulInstall(t *testing.T) { - var calls [][]string - m := &uvManager{bin: "uv", runFn: func(_ context.Context, args []string, _ string) (string, error) { - calls = append(calls, args) - return "", nil - }} + ctx, stub := process.WithStub(t.Context()) + stub.WithCallback(func(_ *exec.Cmd) error { return nil }) + m := &uvManager{bin: "uv"} - selection, err := m.EnsurePython(t.Context(), "3.12", "==3.12.*") + selection, err := m.EnsurePython(ctx, "3.12", "==3.12.*") require.NoError(t, err) assert.Equal(t, PythonSelection{ Executable: "3.12", Resolution: PythonResolutionUVInstallSucceeded, }, selection) - assert.Equal(t, [][]string{{"uv", "python", "install", "3.12"}}, calls) + assert.Equal(t, []string{uvStubCommand("python install 3.12")}, stub.Commands()) } func TestEnsurePythonFallsBackToInstalledInterpreter(t *testing.T) { - var calls [][]string - m := &uvManager{bin: "uv", runFn: func(_ context.Context, args []string, _ string) (string, error) { - calls = append(calls, args) - switch len(calls) { - case 1: - return "", errors.New("download blocked") - case 2: - return `[{"version_parts":{"major":3,"minor":12,"patch":10},"path":"/managed/python3.12"}]`, nil - case 3: - return `[{"version_parts":{"major":3,"minor":12,"patch":11},"path":"/system/python3.12"}]`, nil - default: - t.Fatalf("unexpected extra uv call: %v", args) - return "", nil - } - }} + ctx, stub := process.WithStub(t.Context()) + stub.WithFailureFor(uvPythonInstall312Pattern, errors.New("download blocked")) + stub.WithStdoutFor(`--managed-python`, `[{"version_parts":{"major":3,"minor":12,"patch":10},"path":"/managed/python3.12"}]`) + stub.WithStdoutFor(`--no-managed-python`, `[{"version_parts":{"major":3,"minor":12,"patch":11},"path":"/system/python3.12"}]`) + m := &uvManager{bin: "uv"} - selection, err := m.EnsurePython(t.Context(), "3.12", ">=3.12,<3.15") + selection, err := m.EnsurePython(ctx, "3.12", ">=3.12,<3.15") require.NoError(t, err) assert.Equal(t, PythonSelection{ Executable: "/system/python3.12", Resolution: PythonResolutionInstalledFallback, }, selection) - require.Len(t, calls, 3) assert.Equal(t, []string{ - "uv", "python", "list", "--only-installed", "--all-versions", - "--output-format", "json", "--managed-python", "cpython@>=3.12,<3.15,==3.12.*", - }, calls[1]) - assert.Equal(t, []string{ - "uv", "python", "list", "--only-installed", "--all-versions", - "--output-format", "json", "--no-managed-python", "cpython@>=3.12,<3.15,==3.12.*", - }, calls[2]) + uvStubCommand("python install 3.12"), + uvStubCommand("python list --only-installed --all-versions --output-format json --managed-python cpython@>=3.12,<3.15,==3.12.*"), + uvStubCommand("python list --only-installed --all-versions --output-format json --no-managed-python cpython@>=3.12,<3.15,==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(`--managed-python`, `[]`) + stub.WithStdoutFor(`--no-managed-python`, `[{"version_parts":{"major":3,"minor":12,"patch":11},"path":"/system/python3.12"}]`) + + selection, err := (&uvManager{bin: "uv"}).EnsurePython(ctx, "3.12", ">=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", ">=3.12") + + require.ErrorIs(t, err, context.Canceled) + assert.Equal(t, []string{uvStubCommand("python install 3.12")}, stub.Commands()) } func TestEnsurePythonDoesNotRetryWhenFallbackFails(t *testing.T) { - installCalls := 0 - m := &uvManager{bin: "uv", runFn: func(_ context.Context, args []string, _ string) (string, error) { - if len(args) >= 3 && args[1] == "python" && args[2] == "install" { - installCalls++ - return "", errors.New("download blocked") - } - return `[]`, nil - }} + ctx, stub := process.WithStub(t.Context()) + stub.WithFailureFor(uvPythonInstall312Pattern, errors.New("download blocked")) + stub.WithStdoutFor(`python list`, `[]`) + m := &uvManager{bin: "uv"} - selection, err := m.EnsurePython(t.Context(), "3.12", "==3.12.*") + selection, err := m.EnsurePython(ctx, "3.12", "==3.12.*") require.Error(t, err) assert.Empty(t, selection) - assert.Equal(t, 1, installCalls) + assert.Equal(t, 3, stub.Len()) var pe *PipelineError require.ErrorAs(t, err, &pe) assert.Equal(t, ErrPythonInstall, pe.Code) @@ -358,6 +408,14 @@ func TestUvFailureIncludesStderr(t *testing.T) { assert.Equal(t, ErrProvision, pe.Code) assert.Equal(t, "uv sync failed", pe.Msg) }) + + t.Run("includes_stderr_from_joined_fallback_failure", func(t *testing.T) { + installErr := &process.ProcessError{Err: errors.New("exit status 1"), Stderr: "download blocked"} + listErr := &process.ProcessError{Err: errors.New("exit status 2"), Stderr: "unexpected argument '--managed-python'"} + pe := uvFailure(ErrPythonInstall, errors.Join(installErr, listErr), "uv python install 3.12") + assert.Contains(t, pe.Msg, "download blocked") + assert.Contains(t, pe.Msg, "unexpected argument '--managed-python'") + }) } func TestConfirmUvInstall(t *testing.T) { From fe30a9413efad27de7ec497494586a109f0a6566 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Tue, 1 Sep 2026 18:35:37 +0200 Subject: [PATCH 4/6] localenv: use uv python find for installed fallback Replace the hand-rolled discovery (`uv python list` for managed and system interpreters, JSON parsing, and version selection) with a single `uv python find --system --no-python-downloads cpython@==.*` call. uv already applies its own managed-first preference and excludes project venvs, so this deletes selectInstalledPython, newerInstalledPython, listInstalledPython, pythonListArgs, fallbackPythonConstraint, and the JSON records they parsed. Also: log the download failure reason on the fallback-success path, and keep the download failure and empty-search outcome in separate sentences so the message no longer reads as if `python install` rejected an argument it never received. Co-authored-by: Isaac --- libs/localenv/pipeline.go | 6 +- libs/localenv/pipeline_test.go | 9 +- libs/localenv/pkgmanager.go | 10 +- libs/localenv/uv.go | 208 +++++++++------------------------ libs/localenv/uv_test.go | 158 ++++++++++--------------- 5 files changed, 124 insertions(+), 267 deletions(-) diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 16477079c3e..d1748880765 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -247,7 +247,7 @@ func (p *Pipeline) run(ctx context.Context) error { // Phase: provision — ensure Python, run uv sync, seed pip. p.report(ctx, PhaseProvision) - if err := p.provision(ctx, pyMinor, c.RequiresPython); err != nil { + if err := p.provision(ctx, pyMinor); err != nil { return err } @@ -492,8 +492,8 @@ 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, constraint string) error { - selection, err := p.PM.EnsurePython(ctx, pyMinor, constraint) +func (p *Pipeline) provision(ctx context.Context, pyMinor string) error { + selection, err := p.PM.EnsurePython(ctx, pyMinor) if err != nil { return p.fail(PhaseProvision, true, asPipelineError(err, ErrPythonInstall, "ensure python %s failed", pyMinor)) } diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index c1cba4810c0..5361c44fb47 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -31,7 +31,7 @@ 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, minor, _ string) (PythonSelection, error) { +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 } @@ -50,7 +50,7 @@ func (noProvisionPM) EnsureAvailable(context.Context) (string, error) { return "", errors.New("EnsureAvailable must not be called under --dry-run") } -func (noProvisionPM) EnsurePython(context.Context, string, string) (PythonSelection, error) { +func (noProvisionPM) EnsurePython(context.Context, string) (PythonSelection, error) { return PythonSelection{}, errors.New("EnsurePython must not be called under --dry-run") } @@ -78,14 +78,12 @@ func (uvMissingPM) EnsureAvailable(context.Context) (string, error) { type recordingPM struct { fakePM minor string - constraint string provisionPython string provisionErr error } -func (p *recordingPM) EnsurePython(_ context.Context, minor, constraint string) (PythonSelection, error) { +func (p *recordingPM) EnsurePython(_ context.Context, minor string) (PythonSelection, error) { p.minor = minor - p.constraint = constraint return PythonSelection{ Executable: "/installed/python3.12", Resolution: PythonResolutionInstalledFallback, @@ -148,7 +146,6 @@ func TestPipelineProvisionsWithSelectedPython(t *testing.T) { require.NoError(t, err) assert.Equal(t, "3.12", pm.minor) - assert.Equal(t, "==3.12.*", pm.constraint) assert.Equal(t, "/installed/python3.12", pm.provisionPython) assert.Equal(t, PythonResolutionInstalledFallback, res.PythonResolution) } diff --git a/libs/localenv/pkgmanager.go b/libs/localenv/pkgmanager.go index 9ba67ab773b..774bc11e569 100644 --- a/libs/localenv/pkgmanager.go +++ b/libs/localenv/pkgmanager.go @@ -12,9 +12,9 @@ type PackageManager interface { EnsureAvailable(ctx context.Context) (version string, err error) // EnsurePython first asks the package manager to install the requested minor. - // If that fails, it may select an already-installed interpreter satisfying - // constraint. The returned executable is passed unchanged to Provision. - EnsurePython(ctx context.Context, minor, constraint string) (PythonSelection, error) + // 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 python, which is either a minor request (e.g. "3.12") or an @@ -35,8 +35,8 @@ type PackageManager interface { } // PythonResolution records which preparation path supplied the interpreter. -// It is deliberately categorical: executable paths and versions never belong -// in telemetry derived from the structured result. +// 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 ( diff --git a/libs/localenv/uv.go b/libs/localenv/uv.go index c065596e184..3daff2cb99b 100644 --- a/libs/localenv/uv.go +++ b/libs/localenv/uv.go @@ -3,9 +3,7 @@ package localenv import ( "bufio" "context" - "encoding/json" "errors" - "fmt" "net/url" "os" "os/exec" @@ -31,73 +29,6 @@ type uvManager struct { bin string } -// uvPython is the subset of uv's JSON-formatted Python installation record -// needed to choose an executable. -type uvPython struct { - VersionParts struct { - Major int `json:"major"` - Minor int `json:"minor"` - Patch int `json:"patch"` - } `json:"version_parts"` - Path string `json:"path"` -} - -// installedPython associates a uv installation record with its source so an -// equal-version tie can prefer a uv-managed interpreter. -type installedPython struct { - uvPython - managed bool -} - -// selectInstalledPython returns the highest compatible patch from uv's managed -// and system installation lists. A managed interpreter wins an exact tie. -func selectInstalledPython(managedJSON, systemJSON []byte) (string, error) { - var managed, system []uvPython - if err := json.Unmarshal(managedJSON, &managed); err != nil { - return "", fmt.Errorf("parse managed Python installations: %w", err) - } - if err := json.Unmarshal(systemJSON, &system); err != nil { - return "", fmt.Errorf("parse system Python installations: %w", err) - } - - var best *installedPython - for _, group := range []struct { - pythons []uvPython - managed bool - }{{system, false}, {managed, true}} { - for _, python := range group.pythons { - candidate := installedPython{uvPython: python, managed: group.managed} - if python.Path == "" { - continue - } - if best == nil || newerInstalledPython(candidate, *best) { - best = &candidate - } - } - } - if best == nil { - return "", errors.New("no compatible installed Python found") - } - return best.Path, nil -} - -// newerInstalledPython reports whether candidate should replace current. Patch -// version is the primary product preference; uv-managed wins only an exact tie. -func newerInstalledPython(candidate, current installedPython) bool { - c := candidate.VersionParts - b := current.VersionParts - if c.Major != b.Major { - return c.Major > b.Major - } - if c.Minor != b.Minor { - return c.Minor > b.Minor - } - if c.Patch != b.Patch { - return c.Patch > b.Patch - } - return candidate.managed && !current.managed -} - // NewUvManager returns a PackageManager backed by the uv tool. The binary path // is resolved lazily via EnsureAvailable. func NewUvManager() PackageManager { @@ -161,8 +92,9 @@ func (m *uvManager) runUvOutput(ctx context.Context, args []string, dir string) 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, constraint string) (PythonSelection, 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)...) installErr := m.runUv(ctx, args, "") if installErr == nil { @@ -171,66 +103,58 @@ func (m *uvManager) EnsurePython(ctx context.Context, minor, constraint string) if ctx.Err() != nil { return PythonSelection{}, uvFailure(ErrPythonInstall, installErr, "uv python install "+minor) } - fallbackConstraint := fallbackPythonConstraint(constraint, minor) - - managed, err := m.listInstalledPython(ctx, fallbackConstraint, true) - if err != nil { - return PythonSelection{}, uvFailure(ErrPythonInstall, errors.Join(installErr, err), "uv python install "+minor) - } - system, err := m.listInstalledPython(ctx, fallbackConstraint, false) - if err != nil { - return PythonSelection{}, uvFailure(ErrPythonInstall, errors.Join(installErr, err), "uv python install "+minor) - } - executable, err := selectInstalledPython(managed, system) - if err != nil { - return PythonSelection{}, uvFailure(ErrPythonInstall, errors.Join(installErr, err), "uv python install "+minor) - } - log.Debugf(ctx, "uv: selected compatible installed Python at %s", executable) - return PythonSelection{Executable: executable, Resolution: PythonResolutionInstalledFallback}, nil -} - -// fallbackPythonConstraint converts the accepted shorthand forms of -// requires-python to specifiers uv can parse, then pins discovery to the target -// minor. See https://docs.astral.sh/uv/concepts/python-versions/#requesting-a-version -func fallbackPythonConstraint(constraint, minor string) string { - clauses := make([]string, 0, strings.Count(constraint, ",")+2) - minorPin := "==" + minor + ".*" - hasMinorPin := false - for clause := range strings.SplitSeq(constraint, ",") { - clause = strings.TrimSpace(clause) - if clause == "" { - continue + 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 { + // Two sentences, each carrying its own command's stderr: the download + // failure and the search failure are distinct, so the find stderr attaches + // to the search sentence rather than reading as if `python install` + // rejected an argument it never saw. + msg := "uv python install " + minor + " failed" + if stderr := uvStderr(installErr); stderr != "" { + msg += ": " + stderr } - match := clauseRe.FindStringSubmatch(clause) - if match != nil && match[1] == "" { - clause = ">=" + clause + msg += "\nno compatible installed Python found" + if stderr := uvStderr(findErr); stderr != "" { + msg += ": " + stderr } - hasMinorPin = hasMinorPin || clause == minorPin - clauses = append(clauses, clause) - } - if !hasMinorPin { - clauses = append(clauses, minorPin) + return PythonSelection{}, NewError(ErrPythonInstall, errors.Join(installErr, findErr), "%s", msg) } - return strings.Join(clauses, ",") + log.Debugf(ctx, "uv: Python download failed (%s); using installed interpreter %s", uvStderr(installErr), executable) + return PythonSelection{Executable: executable, Resolution: PythonResolutionInstalledFallback}, nil } -// listInstalledPython asks uv for compatible installed interpreters from one -// source and returns its JSON response unchanged for selection. -func (m *uvManager) listInstalledPython(ctx context.Context, constraint string, managed bool) ([]byte, error) { - args := append([]string{m.bin}, m.pythonListArgs(constraint, managed)...) +// 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 nil, err + return "", err + } + // uv find errors when nothing matches, so an empty path is unexpected; guard + // it anyway to keep an empty --python off the uv sync command line. + path := strings.TrimSpace(out) + if path == "" { + return "", errors.New("uv python find returned no interpreter") } - return []byte(out), nil + return path, nil } // Provision runs `uv sync` inside projectDir to install project dependencies, // 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 pipeline validation against the 3.12 target. -// The explicit request is either the minor just installed or the exact -// compatible path selected by fallback discovery. +// 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 { @@ -356,19 +280,11 @@ func (m *uvManager) pythonInstallArgs(minor string) []string { return []string{"python", "install", minor} } -// pythonListArgs returns the arguments for listing compatible interpreters -// already present on this machine. Splitting managed and system installations -// lets selection prefer a uv-managed interpreter only when versions tie. -// https://docs.astral.sh/uv/reference/cli/#uv-python-list -func (m *uvManager) pythonListArgs(constraint string, managed bool) []string { - preference := "--no-managed-python" - if managed { - preference = "--managed-python" - } - return []string{ - "python", "list", "--only-installed", "--all-versions", - "--output-format", "json", preference, "cpython@" + constraint, - } +// 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. @@ -483,37 +399,19 @@ 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 stderr := processStderr(err); stderr != "" { + if stderr := uvStderr(err); stderr != "" { msg = msg + ": " + stderr } return NewError(code, err, "%s", msg) } -// processStderr collects diagnostics from every process failure in an error -// tree, including errors.Join trees produced by fallback attempts. -func processStderr(err error) string { - return strings.Join(appendProcessStderr(nil, err), "\n") -} - -// appendProcessStderr recursively appends process diagnostics from err while -// preserving the order of errors.Join children. -func appendProcessStderr(messages []string, err error) []string { - if err == nil { - return messages +// 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) } - if joined, ok := err.(interface{ Unwrap() []error }); ok { - for _, child := range joined.Unwrap() { - messages = appendProcessStderr(messages, child) - } - return messages - } - if perr, ok := err.(*process.ProcessError); ok { - if stderr := strings.TrimSpace(perr.Stderr); stderr != "" { - messages = append(messages, stderr) - } - return messages - } - return appendProcessStderr(messages, errors.Unwrap(err)) + return "" } // confirmUvInstall reports whether the caller has consented to installUv running diff --git a/libs/localenv/uv_test.go b/libs/localenv/uv_test.go index 33f77d95c31..0ff085f1586 100644 --- a/libs/localenv/uv_test.go +++ b/libs/localenv/uv_test.go @@ -53,91 +53,17 @@ func TestUvArgs(t *testing.T) { 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", "list", "--only-installed", "--all-versions", - "--output-format", "json", "--managed-python", "cpython@==3.12.*", - }, m.pythonListArgs("==3.12.*", true)) - assert.Equal(t, []string{ - "python", "list", "--only-installed", "--all-versions", - "--output-format", "json", "--no-managed-python", "cpython@==3.12.*", - }, m.pythonListArgs("==3.12.*", false)) + "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 TestSelectInstalledPython(t *testing.T) { - tests := []struct { - name string - managed string - system string - want string - wantErr bool - }{ - { - name: "newer system patch beats managed", - managed: `[{"version_parts":{"major":3,"minor":12,"patch":10},"path":"/managed/3.12.10"}]`, - system: `[{"version_parts":{"major":3,"minor":12,"patch":11},"path":"/system/3.12.11"}]`, - want: "/system/3.12.11", - }, - { - name: "managed wins an equal-version tie", - managed: `[{"version_parts":{"major":3,"minor":12,"patch":10},"path":"/managed/3.12.10"}]`, - system: `[{"version_parts":{"major":3,"minor":12,"patch":10},"path":"/system/3.12.10"}]`, - want: "/managed/3.12.10", - }, - { - name: "highest candidate within one source wins", - managed: `[{"version_parts":{"major":3,"minor":12,"patch":8},"path":"/managed/3.12.8"},{"version_parts":{"major":3,"minor":12,"patch":12},"path":"/managed/3.12.12"}]`, - system: `[]`, - want: "/managed/3.12.12", - }, - { - name: "entry without a path is skipped", - managed: `[{"version_parts":{"major":3,"minor":12,"patch":12},"path":""},{"version_parts":{"major":3,"minor":12,"patch":10},"path":"/managed/3.12.10"}]`, - system: `[]`, - want: "/managed/3.12.10", - }, - {name: "no candidates", managed: `[]`, system: `[]`, wantErr: true}, - {name: "malformed JSON", managed: `{`, system: `[]`, wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := selectInstalledPython([]byte(tt.managed), []byte(tt.system)) - if tt.wantErr { - require.Error(t, err) - assert.Empty(t, got) - return - } - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestFallbackPythonConstraint(t *testing.T) { - tests := []struct { - name string - constraint string - want string - }{ - {"trims clauses", " >=3.12 , <3.13 ", ">=3.12,<3.13,==3.12.*"}, - {"normalizes bare floor", "3.12", ">=3.12,==3.12.*"}, - {"normalizes bare floor among exclusions", "!=3.11,3.12", "!=3.11,>=3.12,==3.12.*"}, - {"preserves exact minor", "==3.12", "==3.12,==3.12.*"}, - {"preserves arbitrary equality", "===3.12", "===3.12,==3.12.*"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, fallbackPythonConstraint(tt.constraint, "3.12")) - }) - } -} - 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", "==3.12.*") + selection, err := m.EnsurePython(ctx, "3.12") require.NoError(t, err) assert.Equal(t, PythonSelection{ @@ -150,11 +76,10 @@ func TestEnsurePythonStopsAfterSuccessfulInstall(t *testing.T) { func TestEnsurePythonFallsBackToInstalledInterpreter(t *testing.T) { ctx, stub := process.WithStub(t.Context()) stub.WithFailureFor(uvPythonInstall312Pattern, errors.New("download blocked")) - stub.WithStdoutFor(`--managed-python`, `[{"version_parts":{"major":3,"minor":12,"patch":10},"path":"/managed/python3.12"}]`) - stub.WithStdoutFor(`--no-managed-python`, `[{"version_parts":{"major":3,"minor":12,"patch":11},"path":"/system/python3.12"}]`) + stub.WithStdoutFor(`python find`, "/system/python3.12\n") m := &uvManager{bin: "uv"} - selection, err := m.EnsurePython(ctx, "3.12", ">=3.12,<3.15") + selection, err := m.EnsurePython(ctx, "3.12") require.NoError(t, err) assert.Equal(t, PythonSelection{ @@ -163,8 +88,7 @@ func TestEnsurePythonFallsBackToInstalledInterpreter(t *testing.T) { }, selection) assert.Equal(t, []string{ uvStubCommand("python install 3.12"), - uvStubCommand("python list --only-installed --all-versions --output-format json --managed-python cpython@>=3.12,<3.15,==3.12.*"), - uvStubCommand("python list --only-installed --all-versions --output-format json --no-managed-python cpython@>=3.12,<3.15,==3.12.*"), + uvStubCommand("python find --system --no-python-downloads cpython@==3.12.*"), }, stub.Commands()) } @@ -176,10 +100,9 @@ func TestEnsurePythonFallbackPreservesIndexURLBridge(t *testing.T) { 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(`--managed-python`, `[]`) - stub.WithStdoutFor(`--no-managed-python`, `[{"version_parts":{"major":3,"minor":12,"patch":11},"path":"/system/python3.12"}]`) + stub.WithStdoutFor(`python find`, "/system/python3.12\n") - selection, err := (&uvManager{bin: "uv"}).EnsurePython(ctx, "3.12", ">=3.12") + selection, err := (&uvManager{bin: "uv"}).EnsurePython(ctx, "3.12") require.NoError(t, err) assert.Equal(t, "/system/python3.12", selection.Executable) @@ -194,23 +117,70 @@ func TestEnsurePythonStopsFallbackWhenContextIsCanceled(t *testing.T) { return context.Canceled }) - _, err := (&uvManager{bin: "uv"}).EnsurePython(ctx, "3.12", ">=3.12") + _, 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 TestEnsurePythonDoesNotRetryWhenFallbackFails(t *testing.T) { +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) + // Both the download reason and the search command's own stderr must be + // reachable; the latter distinguishes "nothing installed" from a uv that + // rejected the find flags. + assert.Contains(t, pe.Msg, "Connection refused") + assert.Contains(t, pe.Msg, "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.WithStdoutFor(`python list`, `[]`) + stub.WithFailureFor(`python find`, errors.New("No interpreter found")) m := &uvManager{bin: "uv"} - selection, err := m.EnsurePython(ctx, "3.12", "==3.12.*") + selection, err := m.EnsurePython(ctx, "3.12") require.Error(t, err) assert.Empty(t, selection) - assert.Equal(t, 3, stub.Len()) + 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) @@ -408,14 +378,6 @@ func TestUvFailureIncludesStderr(t *testing.T) { assert.Equal(t, ErrProvision, pe.Code) assert.Equal(t, "uv sync failed", pe.Msg) }) - - t.Run("includes_stderr_from_joined_fallback_failure", func(t *testing.T) { - installErr := &process.ProcessError{Err: errors.New("exit status 1"), Stderr: "download blocked"} - listErr := &process.ProcessError{Err: errors.New("exit status 2"), Stderr: "unexpected argument '--managed-python'"} - pe := uvFailure(ErrPythonInstall, errors.Join(installErr, listErr), "uv python install 3.12") - assert.Contains(t, pe.Msg, "download blocked") - assert.Contains(t, pe.Msg, "unexpected argument '--managed-python'") - }) } func TestConfirmUvInstall(t *testing.T) { From 8b97824be740417e5f56949d1cf3d554c51a8a44 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Wed, 2 Sep 2026 14:35:44 +0200 Subject: [PATCH 5/6] localenv: drop unreachable empty-path guard in findInstalledPython uv python find exits non-zero when no interpreter matches (verified with uv 0.12.8), so the empty-stdout branch was dead. Trust the exit code per the repo convention against speculative fallbacks. Co-authored-by: Isaac --- libs/localenv/uv.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/libs/localenv/uv.go b/libs/localenv/uv.go index 3daff2cb99b..14fb731190f 100644 --- a/libs/localenv/uv.go +++ b/libs/localenv/uv.go @@ -140,13 +140,7 @@ func (m *uvManager) findInstalledPython(ctx context.Context, minor string) (stri if err != nil { return "", err } - // uv find errors when nothing matches, so an empty path is unexpected; guard - // it anyway to keep an empty --python off the uv sync command line. - path := strings.TrimSpace(out) - if path == "" { - return "", errors.New("uv python find returned no interpreter") - } - return path, nil + return strings.TrimSpace(out), nil } // Provision runs `uv sync` inside projectDir to install project dependencies, From 4a7859715ad2cce09a9e5b9f9862d7ed245b7990 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Wed, 2 Sep 2026 14:53:01 +0200 Subject: [PATCH 6/6] localenv: address review feedback on installed-Python fallback - Suppress the fallback note on cancellation: after Ctrl-C, "Setup canceled" was followed by a contradictory "Python download failed; used ... installed Python instead". Now shown only on real failures. - Fix the combined install+search error message: the two-sentence split did not survive PipelineError.Error() (which appends the wrapped cause after Msg), gluing the joined causes onto the second sentence. Collapse to one attributed line ("... failed: ; no compatible installed Python found either: ") so each stderr stays attributed and the cause suffix reads naturally, matching the uvFailure convention. - Name the fallback interpreter in the text summary (new json:"-" Result field) so the user can tell which interpreter backs the venv; the structured result stays categorical via PythonResolution. Co-authored-by: Isaac --- cmd/environments/output.go | 6 ++++-- cmd/environments/output_test.go | 21 +++++++++++++++++++-- libs/localenv/pipeline.go | 5 +++++ libs/localenv/pipeline_test.go | 1 + libs/localenv/result.go | 13 +++++++++---- libs/localenv/uv.go | 12 +++++++----- libs/localenv/uv_test.go | 12 +++++++----- 7 files changed, 52 insertions(+), 18 deletions(-) diff --git a/cmd/environments/output.go b/cmd/environments/output.go index f4e6695bb87..490fec152ee 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -61,8 +61,10 @@ 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) } - renderInstalledPythonFallback(ctx, res) cmdio.LogString(ctx, "") cmdio.LogString(ctx, "Re-run with --debug for details, or --output json for a structured report.") // The failing message is already surfaced above; ErrAlreadyPrinted exits @@ -125,7 +127,7 @@ func renderSuccess(ctx context.Context, res *libslocalenv.Result) { func renderInstalledPythonFallback(ctx context.Context, res *libslocalenv.Result) { if res.PythonResolution == libslocalenv.PythonResolutionInstalledFallback { cmdio.LogString(ctx, "") - cmdio.LogString(ctx, "Python download failed; used a compatible installed Python instead.") + cmdio.LogString(ctx, "Python download failed; using the installed interpreter "+res.PythonInterpreter+" instead.") } } diff --git a/cmd/environments/output_test.go b/cmd/environments/output_test.go index e7cd14be55e..e9df52b4bb3 100644 --- a/cmd/environments/output_test.go +++ b/cmd/environments/output_test.go @@ -55,11 +55,13 @@ func TestRenderSuccessExplainsInstalledPythonFallback(t *testing.T) { 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") - assert.Contains(t, out, "compatible installed Python") + // 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) { @@ -96,6 +98,7 @@ func TestRenderFailure(t *testing.T) { 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, @@ -105,7 +108,21 @@ func TestRenderFailureExplainsInstalledPythonFallback(t *testing.T) { out := renderText(t, res, res.Error) assert.Contains(t, out, "Python download failed") - assert.Contains(t, out, "compatible installed Python") + 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) { diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index d1748880765..192041767b9 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -501,6 +501,11 @@ func (p *Pipeline) provision(ctx context.Context, pyMinor string) error { // 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")) } diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index 5361c44fb47..5cd97723b68 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -148,6 +148,7 @@ func TestPipelineProvisionsWithSelectedPython(t *testing.T) { 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) { diff --git a/libs/localenv/result.go b/libs/localenv/result.go index 0c0dd26fe94..2f44f167817 100644 --- a/libs/localenv/result.go +++ b/libs/localenv/result.go @@ -313,10 +313,15 @@ type Result struct { Plan *Plan `json:"plan,omitempty"` VenvPath string `json:"venvPath,omitempty"` PythonResolution PythonResolution `json:"pythonResolution,omitempty"` - Phases []PhaseStatus `json:"phases"` - Warnings []Warning `json:"warnings"` - Error *PipelineError `json:"error"` - BackupPath string `json:"backupPath,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/uv.go b/libs/localenv/uv.go index 14fb731190f..8cc5fbfbd38 100644 --- a/libs/localenv/uv.go +++ b/libs/localenv/uv.go @@ -110,15 +110,17 @@ func (m *uvManager) EnsurePython(ctx context.Context, minor string) (PythonSelec return PythonSelection{}, uvFailure(ErrPythonInstall, errors.Join(installErr, findErr), "uv python install "+minor) } if findErr != nil { - // Two sentences, each carrying its own command's stderr: the download - // failure and the search failure are distinct, so the find stderr attaches - // to the search sentence rather than reading as if `python install` - // rejected an argument it never saw. + // 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 += "\nno compatible installed Python found" + msg += "; no compatible installed Python found either" if stderr := uvStderr(findErr); stderr != "" { msg += ": " + stderr } diff --git a/libs/localenv/uv_test.go b/libs/localenv/uv_test.go index 0ff085f1586..d1d8633eaa9 100644 --- a/libs/localenv/uv_test.go +++ b/libs/localenv/uv_test.go @@ -136,11 +136,13 @@ func TestEnsurePythonFallbackErrorSurfacesSearchStderr(t *testing.T) { require.Error(t, err) var pe *PipelineError require.ErrorAs(t, err, &pe) - // Both the download reason and the search command's own stderr must be - // reachable; the latter distinguishes "nothing installed" from a uv that - // rejected the find flags. - assert.Contains(t, pe.Msg, "Connection refused") - assert.Contains(t, pe.Msg, "unexpected argument '--no-python-downloads'") + // 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) {