diff --git a/acceptance/localenv/bundle-multiple-defaults/output.txt b/acceptance/localenv/bundle-multiple-defaults/output.txt index eba9aad3918..9f4228231ea 100644 --- a/acceptance/localenv/bundle-multiple-defaults/output.txt +++ b/acceptance/localenv/bundle-multiple-defaults/output.txt @@ -1,7 +1,5 @@ -preflight ok uv [UV_VERSION] -resolve error No compute target is selected. Select a cluster or serverless target, or pass --cluster-id / --cluster-name / --serverless-version / --job-task -fetch pending -merge pending -provision pending -validate pending -For more detail, re-run with --debug, or --output json to share a structured report. +✗ Setup failed while resolving your compute target. + + No compute target is selected. Select a cluster or serverless target, or pass --cluster-id / --cluster-name / --serverless-version / --job-task + +Re-run with --debug for details, or --output json for a structured report. diff --git a/acceptance/localenv/cluster-name-ambiguous/output.txt b/acceptance/localenv/cluster-name-ambiguous/output.txt index ece61571e20..7b0cf3418bb 100644 --- a/acceptance/localenv/cluster-name-ambiguous/output.txt +++ b/acceptance/localenv/cluster-name-ambiguous/output.txt @@ -1,7 +1,5 @@ -preflight ok check -resolve error resolving cluster name "dup": there are 2 active clusters named "dup"; use --cluster-id to disambiguate -fetch pending -merge pending -provision pending -validate pending -For more detail, re-run with --debug, or --output json to share a structured report. +✗ Setup failed while resolving your compute target. + + resolving cluster name "dup": there are 2 active clusters named "dup"; use --cluster-id to disambiguate + +Re-run with --debug for details, or --output json for a structured report. diff --git a/acceptance/localenv/cluster-name-check/output.txt b/acceptance/localenv/cluster-name-check/output.txt index b8f50099434..02f64fde947 100644 --- a/acceptance/localenv/cluster-name-check/output.txt +++ b/acceptance/localenv/cluster-name-check/output.txt @@ -1,11 +1,5 @@ >>> [CLI] environments setup-local --cluster-name my-cluster --dry-run -preflight ok check -resolve ok source=cluster envKey=dbr/15.4.x-scala2.12 -fetch ok source=[DATABRICKS_URL]/dbr/15.4.x-scala2.12/pyproject.toml fromCache=false -merge ok -provision ok -validate ok Plan: [TEST_TMP_DIR]/pyproject.toml changed region: requires-python changed region: tool.uv.constraint-dependencies diff --git a/acceptance/localenv/cluster-name-unknown/output.txt b/acceptance/localenv/cluster-name-unknown/output.txt index 6a158bb005a..e6a46b095f1 100644 --- a/acceptance/localenv/cluster-name-unknown/output.txt +++ b/acceptance/localenv/cluster-name-unknown/output.txt @@ -1,7 +1,5 @@ -preflight ok check -resolve error resolving cluster name "nope": no active cluster named "nope" -fetch pending -merge pending -provision pending -validate pending -For more detail, re-run with --debug, or --output json to share a structured report. +✗ Setup failed while resolving your compute target. + + resolving cluster name "nope": no active cluster named "nope" + +Re-run with --debug for details, or --output json for a structured report. diff --git a/acceptance/localenv/env-unsupported/output.txt b/acceptance/localenv/env-unsupported/output.txt index c13d1a1d7d8..5ca221a1759 100644 --- a/acceptance/localenv/env-unsupported/output.txt +++ b/acceptance/localenv/env-unsupported/output.txt @@ -1,7 +1,5 @@ -preflight ok check -resolve ok source=cluster envKey=dbr/15.4.x-scala2.12 -fetch error no published environment for "dbr/15.4.x-scala2.12". If this is a new runtime, try the latest LTS target (e.g. --serverless-version 5 or a supported --cluster-id DBR): GET [DATABRICKS_URL]/dbr/15.4.x-scala2.12/pyproject.toml: environment key not found -merge pending -provision pending -validate pending -For more detail, re-run with --debug, or --output json to share a structured report. +✗ Setup failed while fetching constraints. + + no published environment for "dbr/15.4.x-scala2.12". If this is a new runtime, try the latest LTS target (e.g. --serverless-version 5 or a supported --cluster-id DBR): GET [DATABRICKS_URL]/dbr/15.4.x-scala2.12/pyproject.toml: environment key not found + +Re-run with --debug for details, or --output json for a structured report. diff --git a/acceptance/localenv/flag-conflict/output.txt b/acceptance/localenv/flag-conflict/output.txt index 85a4bd285e0..e455d9e9f11 100644 --- a/acceptance/localenv/flag-conflict/output.txt +++ b/acceptance/localenv/flag-conflict/output.txt @@ -1,7 +1,5 @@ -preflight error invalid compute target flags: flags --cluster-id and --serverless-version are mutually exclusive; specify at most one -resolve pending -fetch pending -merge pending -provision pending -validate pending -For more detail, re-run with --debug, or --output json to share a structured report. +✗ Setup failed during preflight checks. + + invalid compute target flags: flags --cluster-id and --serverless-version are mutually exclusive; specify at most one + +Re-run with --debug for details, or --output json for a structured report. diff --git a/acceptance/localenv/help/output.txt b/acceptance/localenv/help/output.txt index b024e671819..4ff2f8ac15b 100644 --- a/acceptance/localenv/help/output.txt +++ b/acceptance/localenv/help/output.txt @@ -1,14 +1,20 @@ -Provision (or update) a local Python environment matched to a Databricks compute target. +Set up a local Python environment that matches a Databricks cluster or serverless version, so code you run on your machine behaves the same as it does on Databricks. -Resolves the target to an environment key, fetches the pinned Python version, -databricks-connect version, and dependency constraints published for that key, -then provisions a matched .venv with uv. A project with no pyproject.toml is -initialized from scratch; an existing pyproject.toml is merged in place (its -env-owned sections are refreshed, user-owned content is preserved). +Use this when you want to develop or debug a Databricks project locally: it installs the matching Python version and a compatible databricks-connect, and pins your dependencies to versions known to work with your chosen compute. It creates or updates a .venv (managed by uv) in the current directory and records the setup in pyproject.toml, leaving the rest of your project untouched. Usage: databricks environments setup-local [flags] +Examples: + # Match a serverless version + databricks environments setup-local --serverless-version 5 + + # Match an existing cluster by name + databricks environments setup-local --cluster-name my-cluster + + # See what would change without writing anything + databricks environments setup-local --serverless-version 5 --dry-run + Flags: --cluster-id string cluster ID to use as the compute target --cluster-name string cluster name to use as the compute target (resolved to an ID via the Clusters API) diff --git a/acceptance/localenv/job-classic-check/output.txt b/acceptance/localenv/job-classic-check/output.txt index b3fa6654679..d42991e5e03 100644 --- a/acceptance/localenv/job-classic-check/output.txt +++ b/acceptance/localenv/job-classic-check/output.txt @@ -1,11 +1,5 @@ >>> [CLI] environments setup-local --job-task 12345.ingest --dry-run -preflight ok check -resolve ok source=job envKey=dbr/15.4.x-scala2.12 -fetch ok source=[DATABRICKS_URL]/dbr/15.4.x-scala2.12/pyproject.toml fromCache=false -merge ok -provision ok -validate ok Plan: [TEST_TMP_DIR]/pyproject.toml changed region: requires-python changed region: tool.uv.constraint-dependencies diff --git a/acceptance/localenv/job-serverless-check/output.txt b/acceptance/localenv/job-serverless-check/output.txt index a86c32d2cb3..5c11f8707a9 100644 --- a/acceptance/localenv/job-serverless-check/output.txt +++ b/acceptance/localenv/job-serverless-check/output.txt @@ -1,11 +1,5 @@ >>> [CLI] environments setup-local --job-task 12345.transform --dry-run -preflight ok check -resolve ok source=job envKey=serverless/serverless-v3 -fetch ok source=[DATABRICKS_URL]/serverless/serverless-v3/pyproject.toml fromCache=false -merge ok -provision ok -validate ok Plan: [TEST_TMP_DIR]/pyproject.toml changed region: requires-python changed region: tool.uv.constraint-dependencies diff --git a/acceptance/localenv/job-task-foreach/output.txt b/acceptance/localenv/job-task-foreach/output.txt index 6587b3e2135..1b6110d05fd 100644 --- a/acceptance/localenv/job-task-foreach/output.txt +++ b/acceptance/localenv/job-task-foreach/output.txt @@ -1,11 +1,5 @@ >>> [CLI] environments setup-local --job-task 12345.fanout --dry-run -preflight ok check -resolve ok source=job envKey=serverless/serverless-v3 -fetch ok source=[DATABRICKS_URL]/serverless/serverless-v3/pyproject.toml fromCache=false -merge ok -provision ok -validate ok Plan: [TEST_TMP_DIR]/pyproject.toml changed region: requires-python changed region: tool.uv.constraint-dependencies diff --git a/acceptance/localenv/job-task-jobcluster/output.txt b/acceptance/localenv/job-task-jobcluster/output.txt index b3fa6654679..d42991e5e03 100644 --- a/acceptance/localenv/job-task-jobcluster/output.txt +++ b/acceptance/localenv/job-task-jobcluster/output.txt @@ -1,11 +1,5 @@ >>> [CLI] environments setup-local --job-task 12345.ingest --dry-run -preflight ok check -resolve ok source=job envKey=dbr/15.4.x-scala2.12 -fetch ok source=[DATABRICKS_URL]/dbr/15.4.x-scala2.12/pyproject.toml fromCache=false -merge ok -provision ok -validate ok Plan: [TEST_TMP_DIR]/pyproject.toml changed region: requires-python changed region: tool.uv.constraint-dependencies diff --git a/acceptance/localenv/job-task-missing-key/output.txt b/acceptance/localenv/job-task-missing-key/output.txt index 75f44248d43..bf8ded1bfe5 100644 --- a/acceptance/localenv/job-task-missing-key/output.txt +++ b/acceptance/localenv/job-task-missing-key/output.txt @@ -1,7 +1,5 @@ -preflight ok check -resolve error specify a job task: job 12345 has multiple tasks; specify one: --job-task 12345. (available: ingest, transform) -fetch pending -merge pending -provision pending -validate pending -For more detail, re-run with --debug, or --output json to share a structured report. +✗ Setup failed while resolving your compute target. + + specify a job task: job 12345 has multiple tasks; specify one: --job-task 12345. (available: ingest, transform) + +Re-run with --debug for details, or --output json for a structured report. diff --git a/acceptance/localenv/job-task-unknown/output.txt b/acceptance/localenv/job-task-unknown/output.txt index 7bd03a24d26..538d8750beb 100644 --- a/acceptance/localenv/job-task-unknown/output.txt +++ b/acceptance/localenv/job-task-unknown/output.txt @@ -1,7 +1,5 @@ -preflight ok check -resolve error resolving job task 12345.nope: job 12345 has no task "nope" (available: ingest) -fetch pending -merge pending -provision pending -validate pending -For more detail, re-run with --debug, or --output json to share a structured report. +✗ Setup failed while resolving your compute target. + + resolving job task 12345.nope: job 12345 has no task "nope" (available: ingest) + +Re-run with --debug for details, or --output json for a structured report. diff --git a/acceptance/localenv/job-task-unpinned/output.txt b/acceptance/localenv/job-task-unpinned/output.txt index b9a0f8ebc4b..6b2ca8503c2 100644 --- a/acceptance/localenv/job-task-unpinned/output.txt +++ b/acceptance/localenv/job-task-unpinned/output.txt @@ -1,7 +1,5 @@ -preflight ok check -resolve error resolving job task 12345.transform: task "transform" of job 12345 binds environment "default", which records no environment version -fetch pending -merge pending -provision pending -validate pending -For more detail, re-run with --debug, or --output json to share a structured report. +✗ Setup failed while resolving your compute target. + + resolving job task 12345.transform: task "transform" of job 12345 binds environment "default", which records no environment version + +Re-run with --debug for details, or --output json for a structured report. diff --git a/acceptance/localenv/manager-unsupported/output.txt b/acceptance/localenv/manager-unsupported/output.txt index 3a484fd1235..35fd6659ad3 100644 --- a/acceptance/localenv/manager-unsupported/output.txt +++ b/acceptance/localenv/manager-unsupported/output.txt @@ -1,7 +1,5 @@ -preflight error detected a conda project; automated setup for conda is not yet available (P1). Use a uv project (add a pyproject.toml with a [tool.uv] table, or run `uv init`) to provision automatically -resolve pending -fetch pending -merge pending -provision pending -validate pending -For more detail, re-run with --debug, or --output json to share a structured report. +✗ Setup failed during preflight checks. + + detected a conda project; automated setup for conda is not yet available (P1). Use a uv project (add a pyproject.toml with a [tool.uv] table, or run `uv init`) to provision automatically + +Re-run with --debug for details, or --output json for a structured report. diff --git a/acceptance/localenv/merge-warnings/output.txt b/acceptance/localenv/merge-warnings/output.txt index fd719fb5dd5..89bf2437070 100644 --- a/acceptance/localenv/merge-warnings/output.txt +++ b/acceptance/localenv/merge-warnings/output.txt @@ -1,11 +1,5 @@ >>> [CLI] environments setup-local --serverless-version 4 --dry-run -preflight ok check -resolve ok source=serverless envKey=serverless/serverless-v4 -fetch ok source=[DATABRICKS_URL]/serverless/serverless-v4/pyproject.toml fromCache=false -merge ok -provision ok -validate ok warning: requires-python ">=3.10" is replaced by the environment's ">=3.12" warning: databricks-connect "databricks-connect~=16.0.0" is replaced by the environment's "databricks-connect~=17.2.0" warning: databricks-connect "databricks-connect==15.0.0" is not rewritten by the merge; the environment's "databricks-connect~=17.2.0" sits in "dev" alongside it, and no version satisfies both diff --git a/acceptance/localenv/no-target/output.txt b/acceptance/localenv/no-target/output.txt index eba9aad3918..9f4228231ea 100644 --- a/acceptance/localenv/no-target/output.txt +++ b/acceptance/localenv/no-target/output.txt @@ -1,7 +1,5 @@ -preflight ok uv [UV_VERSION] -resolve error No compute target is selected. Select a cluster or serverless target, or pass --cluster-id / --cluster-name / --serverless-version / --job-task -fetch pending -merge pending -provision pending -validate pending -For more detail, re-run with --debug, or --output json to share a structured report. +✗ Setup failed while resolving your compute target. + + No compute target is selected. Select a cluster or serverless target, or pass --cluster-id / --cluster-name / --serverless-version / --job-task + +Re-run with --debug for details, or --output json for a structured report. diff --git a/acceptance/localenv/serverless-check/output.txt b/acceptance/localenv/serverless-check/output.txt index 2b36b789ace..0dae505cd2f 100644 --- a/acceptance/localenv/serverless-check/output.txt +++ b/acceptance/localenv/serverless-check/output.txt @@ -1,11 +1,5 @@ >>> [CLI] environments setup-local --serverless-version 4 --dry-run -preflight ok check -resolve ok source=serverless envKey=serverless/serverless-v4 -fetch ok source=[DATABRICKS_URL]/serverless/serverless-v4/pyproject.toml fromCache=false -merge ok -provision ok -validate ok Plan: [TEST_TMP_DIR]/pyproject.toml changed region: requires-python changed region: tool.uv.constraint-dependencies diff --git a/cmd/environments/output.go b/cmd/environments/output.go index 85d60d5c235..586b0058367 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -3,6 +3,8 @@ package environments import ( "context" "fmt" + "runtime" + "strings" "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/cmdio" @@ -13,7 +15,9 @@ import ( // renderResult renders the pipeline result to the command's output. // In JSON mode it renders the full structured result (even on error). -// In text mode it prints phase headers and a summary, then returns the error. +// In text mode it prints a friendly success/failure summary (per-phase progress +// is shown live via the spinner while the run is in flight), then returns the +// error. // // res is always non-nil: Pipeline.Run constructs and returns a fully-populated // Result (with the canonical phase list and error object) on every path, @@ -32,30 +36,35 @@ func renderResult(ctx context.Context, cmd *cobra.Command, res *libslocalenv.Res return nil } - // Text mode: print each phase in execution order. - for _, phase := range res.Phases { - if phase.Detail != "" { - cmdio.LogString(ctx, fmt.Sprintf("%-10s %s %s", phase.Phase, phase.Status, phase.Detail)) - } else { - cmdio.LogString(ctx, fmt.Sprintf("%-10s %s", phase.Phase, phase.Status)) - } - } - + // Text mode. The internal phase log is intentionally NOT printed on success: + // it read as noise in the M5 bug bash (DECO-27977). Per-phase progress is shown + // live via the spinner reporter (see cmd/environments/progress.go); the full + // phase list remains in --output json, and --debug logs each phase as it is + // entered. for _, w := range res.Warnings { cmdio.LogString(ctx, "warning: "+w.Message) } if pipelineErr != nil { - cmdio.LogString(ctx, "For more detail, re-run with --debug, or --output json to share a structured report.") - // The failing phase's message was already printed by the phase loop above - // (Pipeline.fail sets the errored phase's Detail to the error text). - // Returning pipelineErr would make root print "Error: ..." with the same - // message again, since PipelineError.Unwrap yields the cause, not the - // ErrAlreadyPrinted sentinel. Signal already-printed to exit non-zero once. + if res.Error != nil && res.Error.Code == libslocalenv.ErrCanceled { + cmdio.LogString(ctx, "✗ Setup canceled.") + } else { + cmdio.LogString(ctx, "✗ Setup failed"+failureClause(res)+".") + if res.Error != nil { + cmdio.LogString(ctx, "") + // Indent every line: uv-driven failures fold uv's (often multi-line) + // stderr into the message, and a flush-left continuation reads as + // unrelated output rather than part of the reason. + cmdio.LogString(ctx, indent(res.Error.Error(), " ")) + } + } + 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 + // non-zero without root re-printing "Error: ...". return root.ErrAlreadyPrinted } - // Print a final success / check summary. if res.DryRun { if res.Plan != nil { cmdio.LogString(ctx, "Plan: "+res.Plan.WouldWrite) @@ -67,15 +76,90 @@ func renderResult(ctx context.Context, cmd *cobra.Command, res *libslocalenv.Res return nil } + renderSuccess(ctx, res) + return nil +} + +// renderSuccess prints the friendly post-provision summary (DECO-27977). +// +// It runs only on a non-dry-run success (renderResult returns earlier for JSON, +// failures, and dry runs), so res.VenvPath is always set: the validate phase — the +// last thing a successful run does — assigns it unconditionally (see Pipeline.validate). +func renderSuccess(ctx context.Context, res *libslocalenv.Result) { + cmdio.LogString(ctx, "✔ Local environment ready") + cmdio.LogString(ctx, "") + + if res.Compute != nil { + cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "Compute target", res.Compute.Label())) + } if res.Resolved != nil { - summary := "Success: python=" + res.Resolved.PythonVersion + cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "Python", res.Resolved.PythonVersion)) if res.Resolved.DBConnectVersion != "" { - summary += " databricks-connect=" + res.Resolved.DBConnectVersion - } - if res.VenvPath != "" { - summary += " venv=" + res.VenvPath + cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "databricks-connect", res.Resolved.DBConnectVersion)) } - cmdio.LogString(ctx, summary) } - return nil + cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "Virtual env", res.VenvPath)) + // pyproject.toml was created (greenfield) or updated in place (with a backup). + pyprojectDetail := "updated" + if res.Greenfield { + pyprojectDetail = "created" + } else if res.BackupPath != "" { + pyprojectDetail = "updated (backup: " + res.BackupPath + ")" + } + cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "pyproject.toml", pyprojectDetail)) + + 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") +} + +// 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 +// the same way); "source" is a POSIX-shell builtin, so Windows gets the bare +// path instead. Printing the Unix form on Windows would hand the user a command +// that fails with "source is not recognized" and a path that does not exist. +func activateHint(venvPath string) string { + if runtime.GOOS == "windows" { + return venvPath + `\Scripts\activate` + } + return "source " + venvPath + "/bin/activate" +} + +// failureClause maps the failing phase to a human clause for the failure line, +// e.g. " while fetching constraints" (note the leading space). Keyed off the +// recorded FailurePhase so text output stays in step with the --output json +// error object. Returns "" for an unknown or missing phase, so the caller reads +// "Setup failed." rather than the redundant "Setup failed during setup." +func failureClause(res *libslocalenv.Result) string { + if res.Error == nil { + return "" + } + switch res.Error.FailurePhase { + case libslocalenv.PhasePreflight: + return " during preflight checks" + case libslocalenv.PhaseResolve: + return " while resolving your compute target" + case libslocalenv.PhaseFetch: + return " while fetching constraints" + case libslocalenv.PhaseMerge: + return " while updating pyproject.toml" + case libslocalenv.PhaseProvision: + return " while provisioning the virtual environment" + case libslocalenv.PhaseValidate: + return " while validating the environment" + default: + return "" + } +} + +// indent prefixes every line of s with prefix. Used so a multi-line failure +// message (uv stderr folded in) stays visually grouped under the failure line. +func indent(s, prefix string) string { + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = prefix + line + } + return strings.Join(lines, "\n") } diff --git a/cmd/environments/output_test.go b/cmd/environments/output_test.go new file mode 100644 index 00000000000..bf8e5bf4694 --- /dev/null +++ b/cmd/environments/output_test.go @@ -0,0 +1,111 @@ +package environments + +import ( + "runtime" + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/flags" + libslocalenv "github.com/databricks/cli/libs/localenv" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +// renderText runs renderResult in text mode and returns what was written to +// stderr (where cmdio.LogString writes). The command is wired with a proper +// *flags.Output persistent flag so root.OutputType does not panic. +func renderText(t *testing.T, res *libslocalenv.Result, pipelineErr error) string { + t.Helper() + ctx, buf := cmdio.NewTestContextWithStderr(t.Context()) + cmd := &cobra.Command{} + cmd.SetContext(ctx) + // Register the output flag as *flags.Output (text default) so root.OutputType + // finds a *flags.Output value and returns OutputText without panicking. + output := flags.OutputText + cmd.PersistentFlags().VarP(&output, "output", "o", "output type: text or json") + _ = renderResult(ctx, cmd, res, pipelineErr) + return buf.String() +} + +func TestRenderSuccessSummary(t *testing.T) { + res := libslocalenv.NewResult() + res.OK = true + res.Mode = libslocalenv.ModeDefault.String() + res.Compute = &libslocalenv.ComputeInfo{Source: "serverless", ServerlessVersion: "v4", EnvKey: "serverless/serverless-v4"} + res.Resolved = &libslocalenv.ResolvedInfo{PythonVersion: "3.12", DBConnectVersion: "16.1.0"} + res.VenvPath = ".venv" + res.BackupPath = "pyproject.toml.bak" + + out := renderText(t, res, nil) + + assert.Contains(t, out, "Local environment ready") + assert.Contains(t, out, "serverless 4") + assert.Contains(t, out, "3.12") + assert.Contains(t, out, "16.1.0") + assert.Contains(t, out, ".venv") + assert.Contains(t, out, "pyproject.toml.bak") + assert.Contains(t, out, "Next steps") + // The raw phase log must NOT appear on success anymore. + assert.NotContains(t, out, "preflight ok") +} + +func TestRenderSuccessConstraintsOnlyOmitsDBConnect(t *testing.T) { + res := libslocalenv.NewResult() + res.OK = true + res.Mode = libslocalenv.ModeConstraintsOnly.String() + res.Compute = &libslocalenv.ComputeInfo{Source: "serverless", ServerlessVersion: "v4", EnvKey: "serverless/serverless-v4"} + res.Resolved = &libslocalenv.ResolvedInfo{PythonVersion: "3.12"} // no DBConnectVersion + res.VenvPath = ".venv" + + out := renderText(t, res, nil) + // renderSuccess omits the row when DBConnectVersion is empty — which is how constraints-only mode leaves it. + assert.NotContains(t, out, "databricks-connect") +} + +func TestRenderFailure(t *testing.T) { + res := libslocalenv.NewResult() + res.Phases = []libslocalenv.PhaseStatus{} + res.Error = &libslocalenv.PipelineError{ + Code: libslocalenv.ErrFetch, + FailurePhase: libslocalenv.PhaseFetch, + Msg: "constraint repo unreachable", + } + perr := res.Error + + out := renderText(t, res, perr) + + assert.Contains(t, out, "Setup failed") + assert.Contains(t, out, "fetching constraints") + assert.Contains(t, out, "constraint repo unreachable") + assert.Contains(t, out, "--debug") +} + +func TestRenderCanceled(t *testing.T) { + res := libslocalenv.NewResult() + res.Error = &libslocalenv.PipelineError{Code: libslocalenv.ErrCanceled, FailurePhase: libslocalenv.PhaseProvision, Msg: "interrupted"} + out := renderText(t, res, res.Error) + assert.Contains(t, out, "canceled") +} + +func TestRenderDryRun(t *testing.T) { + res := libslocalenv.NewResult() + res.OK = true + res.DryRun = true + res.Plan = &libslocalenv.Plan{WouldWrite: "/tmp/pyproject.toml", ChangedRegions: []string{"requires-python"}} + out := renderText(t, res, nil) + assert.Contains(t, out, "requires-python") + assert.Contains(t, out, "No files were modified") +} + +func TestActivateHint(t *testing.T) { + hint := activateHint(".venv") + if runtime.GOOS == "windows" { + // Windows: uv lays the venv out under Scripts\, and "source" is not a + // cmd/PowerShell builtin, so the hint must not suggest it. + assert.Equal(t, `.venv\Scripts\activate`, hint) + assert.NotContains(t, hint, "source ") + assert.NotContains(t, hint, "/bin/") + } else { + assert.Equal(t, "source .venv/bin/activate", hint) + } +} diff --git a/cmd/environments/progress.go b/cmd/environments/progress.go new file mode 100644 index 00000000000..cebb7049c75 --- /dev/null +++ b/cmd/environments/progress.go @@ -0,0 +1,76 @@ +package environments + +import ( + "context" + + "github.com/databricks/cli/libs/cmdio" + libslocalenv "github.com/databricks/cli/libs/localenv" +) + +// phaseMessages maps each pipeline phase to the live-progress text shown on the +// spinner. Phases without a user-facing message (they complete instantly) are +// omitted and leave the current message in place. +// +// PhasePreflight is intentionally absent: preflight can prompt interactively +// (confirmUvInstall via cmdio.AskYesOrNo when uv is not installed), and the +// prompt writes to the same stderr stream the spinner repaints. A spinner +// running during preflight would clobber the "[y/N]" line and make the command +// look hung. Omitting preflight keeps the spinner from starting until resolve, +// after the prompt has been answered (see spinnerReporter's lazy start). +var phaseMessages = map[libslocalenv.PhaseName]string{ + libslocalenv.PhaseResolve: "Resolving your Databricks compute…", + libslocalenv.PhaseFetch: "Fetching matching versions and constraints…", + libslocalenv.PhaseMerge: "Updating pyproject.toml…", + libslocalenv.PhaseProvision: "Provisioning the virtual environment with uv…", + libslocalenv.PhaseValidate: "Validating the environment…", +} + +// progressSpinner is the subset of cmdio's spinner the reporter drives. It is an +// interface so tests can substitute a fake and assert lazy start. +type progressSpinner interface { + Update(string) + Close() +} + +// spinnerReporter renders live per-phase progress on a cmdio spinner. The spinner +// degrades to a no-op in non-interactive terminals (CI, acceptance tests), so +// this writes nothing there. +// +// The spinner is started lazily, on the first phase that has a message, rather +// than eagerly in the constructor. This keeps its Bubble Tea program — which +// repaints stderr at ~5fps — from running during PhasePreflight, where an +// interactive uv-install prompt may be waiting on the same stream. See +// phaseMessages for why preflight has no message. +type spinnerReporter struct { + // newSpinner constructs the spinner on first use; a field so tests inject a fake. + newSpinner func() progressSpinner + sp progressSpinner +} + +// newSpinnerReporter returns a Reporter that starts a spinner on the first phase +// with a message and updates it as later phases begin. The caller must Close it +// when the run finishes. +func newSpinnerReporter(ctx context.Context) *spinnerReporter { + return &spinnerReporter{newSpinner: func() progressSpinner { return cmdio.NewSpinner(ctx) }} +} + +// PhaseStarted updates the spinner message for the phase that is beginning, +// starting the spinner on first use. Phases without a message (preflight) are a +// no-op, so the spinner never runs while preflight's prompt may be active. +func (r *spinnerReporter) PhaseStarted(name libslocalenv.PhaseName) { + msg, ok := phaseMessages[name] + if !ok { + return + } + if r.sp == nil { + r.sp = r.newSpinner() + } + r.sp.Update(msg) +} + +// Close stops the spinner if it was started. +func (r *spinnerReporter) Close() { + if r.sp != nil { + r.sp.Close() + } +} diff --git a/cmd/environments/progress_test.go b/cmd/environments/progress_test.go new file mode 100644 index 00000000000..7135c2d6a61 --- /dev/null +++ b/cmd/environments/progress_test.go @@ -0,0 +1,63 @@ +package environments + +import ( + "testing" + + libslocalenv "github.com/databricks/cli/libs/localenv" + "github.com/stretchr/testify/assert" +) + +// fakeSpinner records the messages and Close calls the reporter makes. +type fakeSpinner struct { + updates []string + closed int +} + +func (f *fakeSpinner) Update(msg string) { f.updates = append(f.updates, msg) } +func (f *fakeSpinner) Close() { f.closed++ } + +// newTestReporter builds a spinnerReporter whose spinner is a fake, and returns +// both plus a counter of how many times a spinner was constructed. +func newTestReporter() (*spinnerReporter, *fakeSpinner, *int) { + sp := &fakeSpinner{} + created := 0 + r := &spinnerReporter{newSpinner: func() progressSpinner { + created++ + return sp + }} + return r, sp, &created +} + +// Preflight has no message, so the reporter must not construct a spinner while +// preflight's interactive uv-install prompt may be on the same stream. +func TestSpinnerReporterDoesNotStartOnPreflight(t *testing.T) { + r, sp, created := newTestReporter() + + r.PhaseStarted(libslocalenv.PhasePreflight) + + assert.Equal(t, 0, *created, "spinner must not start during preflight") + assert.Empty(t, sp.updates) + + // Close before any message-bearing phase is a no-op (nothing to stop). + r.Close() + assert.Equal(t, 0, sp.closed) +} + +// The spinner starts on the first message-bearing phase and is reused (not +// reconstructed) for later phases; Close stops it once. +func TestSpinnerReporterStartsLazilyAndReuses(t *testing.T) { + r, sp, created := newTestReporter() + + r.PhaseStarted(libslocalenv.PhasePreflight) // no-op + r.PhaseStarted(libslocalenv.PhaseResolve) // starts the spinner + r.PhaseStarted(libslocalenv.PhaseFetch) // reuses it + + assert.Equal(t, 1, *created, "spinner should be constructed exactly once") + assert.Equal(t, []string{ + phaseMessages[libslocalenv.PhaseResolve], + phaseMessages[libslocalenv.PhaseFetch], + }, sp.updates) + + r.Close() + assert.Equal(t, 1, sp.closed) +} diff --git a/cmd/environments/sync.go b/cmd/environments/sync.go index 2adc00eaa85..d7d466cf794 100644 --- a/cmd/environments/sync.go +++ b/cmd/environments/sync.go @@ -9,6 +9,7 @@ import ( "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/flags" libslocalenv "github.com/databricks/cli/libs/localenv" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/logdiag" @@ -18,14 +19,18 @@ import ( func newSetupLocalCommand() *cobra.Command { cmd := &cobra.Command{ Use: libslocalenv.CommandVerb, - Short: "Provision a local Python environment matched to a Databricks compute target", - Long: `Provision (or update) a local Python environment matched to a Databricks compute target. - -Resolves the target to an environment key, fetches the pinned Python version, -databricks-connect version, and dependency constraints published for that key, -then provisions a matched .venv with uv. A project with no pyproject.toml is -initialized from scratch; an existing pyproject.toml is merged in place (its -env-owned sections are refreshed, user-owned content is preserved).`, + Short: "Set up a local Python environment that matches your Databricks compute", + Long: `Set up a local Python environment that matches a Databricks cluster or serverless version, so code you run on your machine behaves the same as it does on Databricks. + +Use this when you want to develop or debug a Databricks project locally: it installs the matching Python version and a compatible databricks-connect, and pins your dependencies to versions known to work with your chosen compute. It creates or updates a .venv (managed by uv) in the current directory and records the setup in pyproject.toml, leaving the rest of your project untouched.`, + Example: ` # Match a serverless version + databricks environments setup-local --serverless-version 5 + + # Match an existing cluster by name + databricks environments setup-local --cluster-name my-cluster + + # See what would change without writing anything + databricks environments setup-local --serverless-version 5 --dry-run`, // Hidden until the environment constraints repository is publicly // available: the command is runnable for dogfooding but stays out of // help and completion until it is unveiled. @@ -39,6 +44,8 @@ env-owned sections are refreshed, user-owned content is preserved).`, // auth configuration in the shared PreRunE so a malformed databricks.yml (e.g. // two targets marked default) can't fail the command before it runs; the fallback // bundle read in bundleTarget swallows such errors and falls through to E_NO_TARGET. + // As a consequence auth resolves from profile/env only: the bundle's + // workspace.host/profile no longer feed the workspace client for this command. cmd.PreRunE = func(cmd *cobra.Command, args []string) error { cmd.SetContext(root.SkipLoadBundle(cmd.Context())) return root.MustWorkspaceClient(cmd, args) @@ -159,6 +166,18 @@ func runPipeline(cmd *cobra.Command) error { } w := cmdctx.WorkspaceClient(ctx) + + // Show live per-phase progress only in text mode. In --output json the only + // thing on stdout must be the JSON object; the spinner writes to stderr and + // no-ops when non-interactive, but we still skip it entirely for JSON so the + // pipeline stays silent for machine consumers. + var rep *spinnerReporter + var progress libslocalenv.Reporter + if root.OutputType(cmd) != flags.OutputJSON { + rep = newSpinnerReporter(ctx) + progress = rep + } + p := &libslocalenv.Pipeline{ Mode: mode, Check: check, @@ -169,9 +188,13 @@ func runPipeline(cmd *cobra.Command) error { Compute: sdkCompute{w: w}, Bundle: bt, PM: libslocalenv.NewUvManager(), + Progress: progress, } res, pipelineErr := p.Run(ctx) + if rep != nil { + rep.Close() + } return renderResult(ctx, cmd, res, pipelineErr) } diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 07a63a5839a..8f772e34c5c 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -54,6 +54,10 @@ type Pipeline struct { Compute ComputeClient PM PackageManager + // Progress, when non-nil, receives a PhaseStarted call as each phase begins. + // Left nil by callers that don't render progress (e.g. --output json). + Progress Reporter + // res accumulates phase statuses and result fields as the run progresses. res *Result } @@ -136,6 +140,7 @@ func (p *Pipeline) run(ctx context.Context) error { // before any other work so the failure flows through the phase/JSON reporting // (a plain Cobra mutual-exclusion error would print no command JSON object, // which the --output json consumer needs). + p.report(ctx, PhasePreflight) if err := ValidateComputeFlags(p.Flags); err != nil { return p.fail(PhasePreflight, false, NewError(ErrUsage, err, "invalid compute target flags")) } @@ -164,12 +169,14 @@ func (p *Pipeline) run(ctx context.Context) error { } // Phase: resolve — compute target → environment key. + p.report(ctx, PhaseResolve) compute, err := p.resolve(ctx) if err != nil { return err } // Phase: fetch — constraint artifact for the resolved env key. + p.report(ctx, PhaseFetch) c, err := p.fetch(ctx, compute) if err != nil { return err @@ -198,6 +205,7 @@ func (p *Pipeline) run(ctx context.Context) error { } // Phase: merge — compute the merged pyproject.toml (in-memory, no writes yet). + p.report(ctx, PhaseMerge) mergedBytes, greenfield, err := p.mergePlan(ctx, pyMinor, c, dbcPin) if err != nil { return err @@ -219,11 +227,13 @@ func (p *Pipeline) run(ctx context.Context) error { p.markOK(PhaseMerge, "") // Phase: provision — ensure Python, run uv sync, seed pip. + p.report(ctx, PhaseProvision) if err := p.provision(ctx, pyMinor); err != nil { return err } // Phase: validate — assert the venv matches the target. + p.report(ctx, PhaseValidate) return p.validate(ctx, pyMinor, dbcPin) } @@ -492,6 +502,15 @@ func initialPhases() []PhaseStatus { return phases } +// report announces entry into a phase to the Progress reporter, if one is set, +// and logs the transition at debug level so --debug keeps a phase-by-phase trail. +func (p *Pipeline) report(ctx context.Context, name PhaseName) { + if p.Progress != nil { + p.Progress.PhaseStarted(name) + } + log.Debugf(ctx, CommandName+": entering phase %s", name) +} + // markOK marks a phase ok with an optional human-readable detail. func (p *Pipeline) markOK(name PhaseName, detail string) { for i := range p.res.Phases { diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index 08c28de2230..41258956d99 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -895,3 +895,29 @@ func phaseStatus(res *Result, name PhaseName) string { } return "" } + +// recordingReporter captures PhaseStarted calls for assertions. +type recordingReporter struct{ started []PhaseName } + +func (r *recordingReporter) PhaseStarted(name PhaseName) { + r.started = append(r.started, name) +} + +func TestPipelineReportsPhaseStarts(t *testing.T) { + dir := writeProject(t) + srv := newTestServer(t) + defer srv.Close() + + rep := &recordingReporter{} + p := &Pipeline{ + Mode: ModeDefault, ProjectDir: dir, + ConstraintBaseURL: srv.URL, CacheDir: t.TempDir(), + Flags: ComputeFlags{Serverless: "v4"}, + Compute: stubCompute{}, PM: fakePM{py: "3.12", dbc: "17.2.0"}, + Progress: rep, + } + _, err := p.Run(t.Context()) + require.NoError(t, err) + // A full successful run enters every phase exactly once in canonical order. + assert.Equal(t, allPhases, rep.started) +} diff --git a/libs/localenv/result.go b/libs/localenv/result.go index 972d9df2a8d..06fb588f9d3 100644 --- a/libs/localenv/result.go +++ b/libs/localenv/result.go @@ -3,6 +3,7 @@ package localenv import ( "encoding/json" "fmt" + "strings" ) // Command path components, defined once so a rename touches a single place @@ -54,6 +55,14 @@ const ( PhaseValidate PhaseName = "validate" ) +// Reporter receives phase-start notifications during a run so a caller can show +// live progress (e.g. a spinner). It is intentionally minimal: the pipeline owns +// success/failure reporting via the Result, and Reporter only marks entry into a +// phase. A nil Reporter disables progress. +type Reporter interface { + PhaseStarted(name PhaseName) +} + // Phase status values (spec §6.2). const ( StatusOK = "ok" @@ -161,6 +170,28 @@ type ComputeInfo struct { SparkVersion string `json:"-"` } +// Label returns a short, human-readable name for the resolved compute target, +// for display in the text summary (e.g. "serverless 4", "cluster 0101-abc", +// "DBR 15.4.x-scala2.12"). The precise environment key is still available in +// --output json and --debug. +func (c *ComputeInfo) Label() string { + switch { + case c.ServerlessVersion != "": + // ServerlessVersion is normalized to "v4"; drop the "v" for display. + return "serverless " + strings.TrimPrefix(c.ServerlessVersion, "v") + case c.ClusterID != "": + return "cluster " + c.ClusterID + case c.SparkVersion != "": + // Classic compute resolved from a --job-task carries no ClusterID (only the + // task's runtime), so without this case Label would fall through and print + // the internal "dbr/..." env key — exactly the detail the summary hides. + // Show the runtime instead, matching the "DBR " phrasing used in help. + return "DBR " + c.SparkVersion + default: + return c.EnvKey + } +} + // ResolvedInfo is the resolved environment definition (spec §6 "resolved"). // DBConnectVersion is omitted in constraints-only mode. type ResolvedInfo struct { diff --git a/libs/localenv/result_test.go b/libs/localenv/result_test.go index f8f2d60c618..db75a0ebf22 100644 --- a/libs/localenv/result_test.go +++ b/libs/localenv/result_test.go @@ -43,3 +43,24 @@ func TestNewResultEmitsEmptyArraysNotNull(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(bare), `"phases":null`, "sanity: bare literal is the null case") } + +func TestComputeInfoLabel(t *testing.T) { + cases := []struct { + name string + info ComputeInfo + want string + }{ + {"serverless", ComputeInfo{Source: "serverless", ServerlessVersion: "v4", EnvKey: "serverless/serverless-v4"}, "serverless 4"}, + {"job serverless", ComputeInfo{Source: "job", ServerlessVersion: "v5", EnvKey: "serverless/serverless-v5"}, "serverless 5"}, + {"cluster", ComputeInfo{Source: "cluster", ClusterID: "0101-abc", EnvKey: "dbr/15.4.x-scala2.12"}, "cluster 0101-abc"}, + // A --job-task bound to classic compute carries only SparkVersion (no + // ClusterID), so it must render the runtime rather than leak the env key. + {"job classic", ComputeInfo{Source: "job", SparkVersion: "15.4.x-scala2.12", EnvKey: "dbr/15.4.x-scala2.12"}, "DBR 15.4.x-scala2.12"}, + {"fallback", ComputeInfo{Source: "bundle", EnvKey: "dbr/15.4.x-scala2.12"}, "dbr/15.4.x-scala2.12"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.info.Label()) + }) + } +}