Skip to content
Closed
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
2 changes: 1 addition & 1 deletion acceptance/localenv/cluster-name-ambiguous-json/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@
"message": "resolving cluster name \"dup\": there are 2 active clusters named \"dup\"; use --cluster-id to disambiguate",
"diskMutated": false
},
"durationMs": 0
"durationMs": [DURATION_MS]
}
2 changes: 1 addition & 1 deletion acceptance/localenv/constraints-only/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,5 @@
],
"warnings": [],
"error": null,
"durationMs": 0
"durationMs": [DURATION_MS]
}
2 changes: 1 addition & 1 deletion acceptance/localenv/flag-conflict-json/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@
"message": "invalid compute target flags: flags --cluster-id and --serverless-version are mutually exclusive; specify at most one",
"diskMutated": false
},
"durationMs": 0
"durationMs": [DURATION_MS]
}
2 changes: 1 addition & 1 deletion acceptance/localenv/json-error/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@
"message": "No compute target is selected. Select a cluster or serverless target, or pass --cluster-id / --cluster-name / --serverless-version / --job-task",
"diskMutated": false
},
"durationMs": 0
"durationMs": [DURATION_MS]
}
3 changes: 3 additions & 0 deletions acceptance/localenv/merge-warnings-json/out.test.toml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 68 additions & 0 deletions acceptance/localenv/merge-warnings-json/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@

>>> [CLI] environments setup-local --serverless-version 4 --dry-run --output json
{
"schemaVersion": 1,
"command": "environments setup-local",
"ok": true,
"mode": "default",
"dryRun": true,
"compute": {
"source": "serverless",
"serverlessVersion": "v4",
"envKey": "serverless/serverless-v4"
},
"resolved": {
"pythonVersion": "3.12",
"dbconnectVersion": "17.2.0",
"artifactSource": "network"
},
"greenfield": false,
"plan": {
"wouldWrite": "[TEST_TMP_DIR]/pyproject.toml",
"wouldBackup": "[TEST_TMP_DIR]/pyproject.toml.bak",
"wouldInstallPython": "3.12",
"diff": "--- pyproject.toml\n+++ pyproject.toml.new\n@@ -1,7 +1,15 @@\n [project]\n name = \"existing\"\n-requires-python = \"\u003e=3.10\"\n+requires-python = \"==3.12.*\"\n dependencies = [\"pyarrow==17.0.0\"]\n \n [dependency-groups]\n-dev = [\"databricks-connect~=16.1.0\"]\n+dev = [\"databricks-connect~=17.2.0\"]\n+\n+# managed by databricks environments setup-local — do not edit\n+[tool.uv]\n+constraint-dependencies = [\n+ \"pyarrow~=21.0.0\",\n+ \"pandas\u003c3\",\n+]\n+# end managed by databricks environments setup-local\n"
},
"phases": [
{
"phase": "preflight",
"status": "ok"
},
{
"phase": "resolve",
"status": "ok"
},
{
"phase": "fetch",
"status": "ok"
},
{
"phase": "merge",
"status": "ok"
},
{
"phase": "provision",
"status": "ok"
},
{
"phase": "validate",
"status": "ok"
}
],
"warnings": [
{
"code": "W_REQUIRES_PYTHON_OVERRIDDEN",
"message": "requires-python \"\u003e=3.10\" was replaced by the environment's \"==3.12.*\""
},
{
"code": "W_DBCONNECT_PIN_OVERRIDDEN",
"message": "databricks-connect \"databricks-connect~=16.1.0\" was replaced by the environment's \"databricks-connect~=17.2.0\""
},
{
"code": "W_USER_CONSTRAINT_CONFLICT",
"message": "dependency \"pyarrow==17.0.0\" conflicts with the environment constraint \"pyarrow~=21.0.0\""
}
],
"error": null,
"durationMs": [DURATION_MS]
}
14 changes: 14 additions & 0 deletions acceptance/localenv/merge-warnings-json/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# A pre-existing project whose pins conflict with the env constraints: an older
# requires-python and databricks-connect, plus a dependency pinned outside the
# env's constraint range. The dry-run merge should surface all three warnings.
cat > pyproject.toml <<'PYPROJECT'
[project]
name = "existing"
requires-python = ">=3.10"
dependencies = ["pyarrow==17.0.0"]

[dependency-groups]
dev = ["databricks-connect~=16.1.0"]
PYPROJECT

trace $CLI environments setup-local --serverless-version 4 --dry-run --output json
21 changes: 21 additions & 0 deletions acceptance/localenv/merge-warnings-json/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"]

# The script writes a pre-existing pyproject.toml to trigger the merge path; it is
# an input the test creates, not a golden to compare.
Ignore = ["pyproject.toml"]

[Env]
DATABRICKS_LOCALENV_CONSTRAINT_SOURCE_URL_TEST_OVERRIDE = "$DATABRICKS_HOST"

[[Server]]
Pattern = "GET /serverless/serverless-v4/pyproject.toml"
Response.Body = '''
[project]
requires-python = "==3.12.*"

[dependency-groups]
dev = ["databricks-connect~=17.2.0"]

[tool.uv]
constraint-dependencies = ["pyarrow~=21.0.0", "pandas<3"]
'''
2 changes: 1 addition & 1 deletion acceptance/localenv/serverless-json/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,5 @@
],
"warnings": [],
"error": null,
"durationMs": 0
"durationMs": [DURATION_MS]
}
7 changes: 7 additions & 0 deletions acceptance/test.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,10 @@ Order = 9
[[Repls]]
Old = "deco-uc-prod-isolated-aws-us-east-1|metastore_azure_eastus2"
New = "[METASTORE_NAME]"

[[Repls]]
# environments setup-local emits a real wall-time durationMs; normalize it so the
# JSON goldens are deterministic. Runs before the generic numeric repls (Order 10).
Old = '"durationMs": \d+'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (#7)localenv is the only thing emitting this key, so this fits better in acceptance/localenv/test.toml. Global scope means a future command that emits durationMs gets it silently normalized in its goldens without anyone opting in.

(The Order = 8 choice is correct — it has to beat the Order = 10 numeric repls. Worth keeping that note wherever it lands.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and moved in #6190 — it now lives in a new acceptance/localenv/test.toml, and the Order = 8 note moved with it, including why it has to beat the root Order = 10 numeric repls.

New = '"durationMs": [DURATION_MS]'
Order = 8
25 changes: 25 additions & 0 deletions libs/localenv/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"time"

"github.com/databricks/cli/libs/log"
"github.com/hexops/gotextdiff"
Expand Down Expand Up @@ -53,10 +54,23 @@ type Pipeline struct {
Compute ComputeClient
PM PackageManager

// Now returns the current time; it exists so tests can inject a deterministic
// clock (acceptance goldens would otherwise carry a real, changing durationMs).
// nil means time.Now — see now().
Now func() time.Time

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (#6) — the stated rationale isn't the real one.

The comment says acceptance goldens "would otherwise carry a real, changing durationMs," but the [DURATION_MS] repl is what makes the goldens deterministic — this field isn't involved. Now is never set anywhere outside pipeline_test.go:276.

So an injectable field on the exported Pipeline struct exists so one test can assert 250, which largely verifies that Sub().Milliseconds() works. Given the repo's stance on speculative flexibility, I'd either drop the field and assert the duration is sane, or keep it and reword the comment to say what it's actually for.

The timing logic itself is right — measuring in Run via defer correctly covers the failure and cancellation paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Now was set only at pipeline_test.go:276, and you are right that the repl — not the clock — is what makes the goldens deterministic, so the stated rationale was wrong.

Took the drop-it option in #6190, given the repo stance on speculative flexibility: an exported field on Pipeline existing for one test did not earn its keep. Timing is now plain time.Now() / time.Since. The replacement test delays the constraint fetch by 25ms and asserts the duration exceeds that and stays within the observed wall time — a >= 0 assertion would have passed against the old hardcoded 0 and proved nothing. A second test covers the preflight-error path.


// res accumulates phase statuses and result fields as the run progresses.
res *Result
}

// now returns the current time via the injected clock, defaulting to time.Now.
func (p *Pipeline) now() time.Time {
if p.Now != nil {
return p.Now()
}
return time.Now()
}

// Run executes all pipeline phases in order and returns a fully populated Result.
// On a phase error, Result.Error is set and the same error is also returned. The
// Result always carries the full canonical phase list: phases completed before a
Expand All @@ -81,6 +95,12 @@ func (p *Pipeline) Run(ctx context.Context) (*Result, error) {
// Phases start as pending and flip to ok/error as the run progresses.
p.res.Phases = initialPhases()

// Measure wall time across every exit path (success and failure alike) so the
// --json durationMs reflects the whole pipeline. Injected clock keeps it
// deterministic under test.
start := p.now()
defer func() { p.res.DurationMs = p.now().Sub(start).Milliseconds() }()

if err := p.run(ctx); err != nil {
// A cancelled context means the user or parent interrupted us (SIGINT/
// SIGTERM). The phase that was running reports its own failure (e.g. uv
Expand Down Expand Up @@ -307,6 +327,11 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints,
if err != nil {
return nil, greenfield, p.fail(PhaseMerge, false, NewError(ErrMerge, err, "merge managed regions failed"))
}
// Surface merge-quality warnings (overridden pins, conflicting user
// constraints) from the pre-merge file. Greenfield has nothing of the
// user's to override, so it is skipped. This runs for both dry-run and real
// runs so the --json consumer sees the same warnings either way.
p.res.Warnings = append(p.res.Warnings, detectMergeWarnings(baseBytes, effective)...)
}

// Under --dry-run, build the plan (with a diff) for reporting. A real run does
Expand Down
65 changes: 65 additions & 0 deletions libs/localenv/pipeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"runtime"
"strings"
"testing"
"time"

"github.com/databricks/cli/libs/process"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -221,6 +222,70 @@ func TestPipelineReportsCancellationNotProvisionFailure(t *testing.T) {
assert.NotContains(t, pe.Error(), "\n", "the error must stay single-line")
}

func TestPipelineSurfacesMergeWarnings(t *testing.T) {
// writeProject pins requires-python ">=3.10" and databricks-connect ~=16.0.0,
// while the server's constraint pins "==3.12.*" and ~=17.2.0 — so a merge
// overrides both, and the result must carry the two override warnings.
dir := writeProject(t)
srv := newTestServer(t)
defer srv.Close()

p := &Pipeline{
Mode: ModeDefault, Check: true, ProjectDir: dir,
ConstraintBaseURL: srv.URL, CacheDir: t.TempDir(),
Flags: ComputeFlags{Serverless: "v4"},
Compute: stubCompute{}, PM: fakePM{py: "3.12", dbc: "17.2.0"},
}
res, err := p.Run(t.Context())
require.NoError(t, err)
assert.Equal(t, []string{WarnRequiresPythonOverridden, WarnDBConnectPinOverridden}, codes(res.Warnings))
}

func TestPipelineGreenfieldHasNoWarnings(t *testing.T) {
// A greenfield project has nothing of the user's to override.
dir := t.TempDir()
srv := newTestServer(t)
defer srv.Close()

p := &Pipeline{
Mode: ModeDefault, Check: true, ProjectDir: dir,
ConstraintBaseURL: srv.URL, CacheDir: t.TempDir(),
Flags: ComputeFlags{Serverless: "v4"},
Compute: stubCompute{}, PM: fakePM{py: "3.12", dbc: "17.2.0"},
}
res, err := p.Run(t.Context())
require.NoError(t, err)
assert.Empty(t, res.Warnings)
assert.True(t, res.Greenfield)
}

func TestPipelineReportsDurationFromInjectedClock(t *testing.T) {
// The injected clock advances 250ms between the first call (start) and the
// second (deferred end), so durationMs is deterministic — not 0, not wall time.
dir := writeProject(t)
srv := newTestServer(t)
defer srv.Close()

base := time.Unix(1_700_000_000, 0)
var calls int
p := &Pipeline{
Mode: ModeDefault, Check: true, ProjectDir: dir,
ConstraintBaseURL: srv.URL, CacheDir: t.TempDir(),
Flags: ComputeFlags{Serverless: "v4"},
Compute: stubCompute{}, PM: fakePM{py: "3.12", dbc: "17.2.0"},
Now: func() time.Time {
calls++
if calls == 1 {
return base
}
return base.Add(250 * time.Millisecond)
},
}
res, err := p.Run(t.Context())
require.NoError(t, err)
assert.Equal(t, int64(250), res.DurationMs)
}

func TestPipelineCheckReRunPlanMatchesRealRun(t *testing.T) {
// On a re-run where the .bak already exists and the live file already equals
// the merged output, --dry-run must report a plan a real run would perform: no
Expand Down
30 changes: 25 additions & 5 deletions libs/localenv/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,32 @@ type PhaseStatus struct {
Detail string `json:"-"`
}

// Warning is a non-fatal advisory surfaced in --json "warnings" (spec §6).
// Warning is a non-fatal advisory surfaced in --json "warnings" (spec §6). Code
// is a stable, categorical identifier from the closed set below; Message is
// human-readable text for the text renderer and is not part of the contract.
type Warning struct {
Code string `json:"code"`
Message string `json:"message"`
}

// Warning codes are the closed, categorical set surfaced in --json warnings[].
// They let a consumer report a count and a code histogram (merge quality) without
// parsing free-form text. All are emitted from the merge phase, where the fetched
// env-owned pins can conflict with what the user already had.
const (
// WarnRequiresPythonOverridden: the user's [project].requires-python differed
// from the env's pin and was replaced by the managed value.
WarnRequiresPythonOverridden = "W_REQUIRES_PYTHON_OVERRIDDEN"
// WarnDBConnectPinOverridden: the user's databricks-connect pin differed from
// the env's pin and was replaced by the managed value.
WarnDBConnectPinOverridden = "W_DBCONNECT_PIN_OVERRIDDEN"
// WarnUserConstraintConflict: a [project].dependencies entry pins a package
// that the env's constraint-dependencies also constrains, to a provably
// non-overlapping version range (uv will likely fail to resolve). Emitted only
// when the ranges are provably disjoint; ambiguous cases are not flagged.
WarnUserConstraintConflict = "W_USER_CONSTRAINT_CONFLICT"
)

// Result is the full outcome of a sync run and the root of the --json object
// (spec §6). Field order matches the spec's schema so JSON key order is stable.
//
Expand All @@ -217,10 +237,10 @@ type Result struct {
Warnings []Warning `json:"warnings"`
Error *PipelineError `json:"error"`
BackupPath string `json:"backupPath,omitempty"`
// DurationMs is part of the §6 contract but reserved for now: the pipeline
// does not measure wall time (a real clock would make acceptance goldens
// non-deterministic), so it is always emitted as 0 until timing is wired
// through a clock the tests can control.
// DurationMs is the pipeline's wall time in milliseconds (spec §6), measured
// through Pipeline.now so tests can inject a deterministic clock. It covers the
// CLI pipeline only; the extension measures its own end-to-end latency (process
// spawn, interpreter adoption) separately.
DurationMs int64 `json:"durationMs"`
}

Expand Down
Loading
Loading