Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nextchanges/cli/setup-local-installed-python-fallback.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions cmd/environments/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down Expand Up @@ -111,13 +114,23 @@ 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:")
cmdio.LogString(ctx, " • Activate it: "+activateHint(res.VenvPath))
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
Expand Down
45 changes: 45 additions & 0 deletions cmd/environments/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}
Expand Down
14 changes: 12 additions & 2 deletions libs/localenv/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
70 changes: 67 additions & 3 deletions libs/localenv/pipeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand All @@ -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),
Expand Down Expand Up @@ -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))
Expand Down
30 changes: 24 additions & 6 deletions libs/localenv/pkgmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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").
Expand Down
34 changes: 20 additions & 14 deletions libs/localenv/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions libs/localenv/result_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading