From d1aad7e7d313ac4e8a61710d9c00da520220c506 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Tue, 1 Sep 2026 17:01:28 +0200 Subject: [PATCH 1/2] Add orthogonal setup-local flags and a "skipped" phase status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *Why* `databricks environments setup-local` had a single `--constraints-only` mode that bundled "skip databricks-connect" together with the rest of the setup. A caller (the VS Code extension, agents) that wants to write project files but defer provisioning, or manage its own dependency pins, had no way to express that. The setup steps are independent, so the flags that control them should be too. *What* Adds three orthogonal, composable negative flags: - `--no-constraints` skips writing the remote Python-version and dependency pins (requires-python and the [tool.uv] constraint block); any existing values are left untouched, and provisioning still installs the resolved Python (the flag governs only what is written). - `--no-dbconnect` skips the databricks-connect dependency. Equivalent to the existing `--constraints-only`, which stays as-is for now. - `--no-provision` writes the project files through the merge phase, then stops: no Python download, uv sync, or validation. Because it never invokes uv, its preflight no longer requires or installs uv either — a files-only run works on a machine without uv. Introduces a new `skipped` phase status (distinct from `pending`, which means an earlier phase failed): the provision and validate phases report `skipped` under `--no-provision`, `venvPath` is omitted, and the dry-run plan drops `wouldInstallPython`. The text summary gains a dedicated "provisioning skipped" variant so it no longer prints an empty venv path or a broken activation hint. The `--no-constraints` "unmanaged" signal is a nil ConstraintDeps / empty requires-python; parseConstraints now normalizes a missing [tool.uv].constraint-dependencies to a non-nil empty slice so that nil uniquely means the flag, not merely an artifact that omits the section. Default runs (no new flags) are byte-for-byte unchanged; the JSON schemaVersion stays at 1 since the new status only appears when a new flag is passed. *Verification* - Unit tests (libs/localenv): no-provision writes files then skips provision/validate without invoking uv, dry-run plus no-provision marks them skipped and drops wouldInstallPython, no-constraints leaves existing pins untouched and omits them greenfield, parseConstraints normalizes missing constraint-dependencies, and the merge/render skip guards. - Acceptance goldens: no-provision (real run), no-constraints, no-dbconnect, no-provision-dry-run, no-provision-text, plus the refreshed help output. - gofmt, go vet, and full go build ./... clean. Co-authored-by: Isaac --- .../cli/setup-local-orthogonal-flags.md | 1 + acceptance/localenv/help/output.txt | 3 + .../localenv/no-constraints/out.test.toml | 2 + acceptance/localenv/no-constraints/output.txt | 60 ++++++++ acceptance/localenv/no-constraints/script | 15 ++ acceptance/localenv/no-constraints/test.toml | 23 +++ .../localenv/no-dbconnect/out.test.toml | 2 + acceptance/localenv/no-dbconnect/output.txt | 59 ++++++++ acceptance/localenv/no-dbconnect/script | 15 ++ acceptance/localenv/no-dbconnect/test.toml | 23 +++ .../no-provision-dry-run/out.test.toml | 2 + .../localenv/no-provision-dry-run/output.txt | 53 +++++++ .../localenv/no-provision-dry-run/script | 4 + .../localenv/no-provision-dry-run/test.toml | 20 +++ .../localenv/no-provision-text/out.test.toml | 2 + .../localenv/no-provision-text/output.txt | 10 ++ acceptance/localenv/no-provision-text/script | 3 + .../localenv/no-provision-text/test.toml | 24 ++++ .../localenv/no-provision/out.test.toml | 2 + acceptance/localenv/no-provision/output.txt | 49 +++++++ acceptance/localenv/no-provision/script | 5 + acceptance/localenv/no-provision/test.toml | 24 ++++ cmd/environments/output.go | 79 ++++++++--- cmd/environments/sync.go | 15 +- libs/localenv/constraints.go | 8 ++ libs/localenv/constraints_test.go | 17 +++ libs/localenv/merge.go | 34 ++++- libs/localenv/merge_test.go | 50 +++++++ libs/localenv/pipeline.go | 99 +++++++++++-- libs/localenv/pipeline_test.go | 132 ++++++++++++++++++ libs/localenv/result.go | 11 +- 31 files changed, 813 insertions(+), 33 deletions(-) create mode 100644 .nextchanges/cli/setup-local-orthogonal-flags.md create mode 100644 acceptance/localenv/no-constraints/out.test.toml create mode 100644 acceptance/localenv/no-constraints/output.txt create mode 100644 acceptance/localenv/no-constraints/script create mode 100644 acceptance/localenv/no-constraints/test.toml create mode 100644 acceptance/localenv/no-dbconnect/out.test.toml create mode 100644 acceptance/localenv/no-dbconnect/output.txt create mode 100644 acceptance/localenv/no-dbconnect/script create mode 100644 acceptance/localenv/no-dbconnect/test.toml create mode 100644 acceptance/localenv/no-provision-dry-run/out.test.toml create mode 100644 acceptance/localenv/no-provision-dry-run/output.txt create mode 100644 acceptance/localenv/no-provision-dry-run/script create mode 100644 acceptance/localenv/no-provision-dry-run/test.toml create mode 100644 acceptance/localenv/no-provision-text/out.test.toml create mode 100644 acceptance/localenv/no-provision-text/output.txt create mode 100644 acceptance/localenv/no-provision-text/script create mode 100644 acceptance/localenv/no-provision-text/test.toml create mode 100644 acceptance/localenv/no-provision/out.test.toml create mode 100644 acceptance/localenv/no-provision/output.txt create mode 100644 acceptance/localenv/no-provision/script create mode 100644 acceptance/localenv/no-provision/test.toml diff --git a/.nextchanges/cli/setup-local-orthogonal-flags.md b/.nextchanges/cli/setup-local-orthogonal-flags.md new file mode 100644 index 00000000000..f71f68acb61 --- /dev/null +++ b/.nextchanges/cli/setup-local-orthogonal-flags.md @@ -0,0 +1 @@ +Added orthogonal `--no-constraints`, `--no-dbconnect`, and `--no-provision` flags to `databricks environments setup-local`. The flags compose: `--no-constraints` skips writing the remote Python-version and dependency pins, `--no-dbconnect` skips the databricks-connect dependency, and `--no-provision` writes the project files but stops before creating the virtual environment. Phases that a flag opts out of report a new `skipped` status in `--output json`. diff --git a/acceptance/localenv/help/output.txt b/acceptance/localenv/help/output.txt index 4ff2f8ac15b..7d455e00d42 100644 --- a/acceptance/localenv/help/output.txt +++ b/acceptance/localenv/help/output.txt @@ -22,6 +22,9 @@ Flags: --dry-run compute the plan without writing files or provisioning -h, --help help for setup-local --job-task string job task to use as the compute target, as . (the task key is required) + --no-constraints skip writing the remote Python version and dependency constraints + --no-dbconnect skip adding the databricks-connect dependency + --no-provision write the project files but skip creating the virtual environment (no uv sync, Python download, or validation) --serverless-version string serverless version to use as the compute target (e.g. 5) Global Flags: diff --git a/acceptance/localenv/no-constraints/out.test.toml b/acceptance/localenv/no-constraints/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/localenv/no-constraints/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/localenv/no-constraints/output.txt b/acceptance/localenv/no-constraints/output.txt new file mode 100644 index 00000000000..6010c343f5a --- /dev/null +++ b/acceptance/localenv/no-constraints/output.txt @@ -0,0 +1,60 @@ + +>>> [CLI] environments setup-local --serverless-version 4 --no-constraints --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@@ -3,4 +3,7 @@\n requires-python = \"\u003e=3.10\"\n \n [dependency-groups]\n-dev = [\"databricks-connect~=16.0\"]\n+dev = [\"databricks-connect~=17.2.0\"]\n+\n+[tool.databricks.environment]\n+environment_version = \"4\"\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_DBCONNECT_PIN_OVERRIDDEN", + "message": "databricks-connect \"databricks-connect~=16.0\" is replaced by the environment's \"databricks-connect~=17.2.0\"" + } + ], + "error": null, + "durationMs": [DURATION_MS] +} diff --git a/acceptance/localenv/no-constraints/script b/acceptance/localenv/no-constraints/script new file mode 100644 index 00000000000..f4711b38dd5 --- /dev/null +++ b/acceptance/localenv/no-constraints/script @@ -0,0 +1,15 @@ +# --no-constraints leaves the remote Python version and dependency pins +# unmanaged: the plan writes neither requires-python nor the [tool.uv] constraint +# block, and the user's existing requires-python is left untouched. The +# databricks-connect dependency (orthogonal to --no-constraints) is still managed. +# The JSON plan shows the diff. +cat > pyproject.toml <<'PY' +[project] +name = "demo" +requires-python = ">=3.10" + +[dependency-groups] +dev = ["databricks-connect~=16.0"] +PY + +trace $CLI environments setup-local --serverless-version 4 --no-constraints --dry-run --output json diff --git a/acceptance/localenv/no-constraints/test.toml b/acceptance/localenv/no-constraints/test.toml new file mode 100644 index 00000000000..6377a54fca8 --- /dev/null +++ b/acceptance/localenv/no-constraints/test.toml @@ -0,0 +1,23 @@ +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +# The script writes pyproject.toml as the merge input; --dry-run leaves it unchanged. +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<19"] +''' + +[[Repls]] +Old = 'uv uv \S+(?: \([^)]+\))?' +New = 'uv [UV_VERSION]' diff --git a/acceptance/localenv/no-dbconnect/out.test.toml b/acceptance/localenv/no-dbconnect/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/localenv/no-dbconnect/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/localenv/no-dbconnect/output.txt b/acceptance/localenv/no-dbconnect/output.txt new file mode 100644 index 00000000000..677a6fbf26b --- /dev/null +++ b/acceptance/localenv/no-dbconnect/output.txt @@ -0,0 +1,59 @@ + +>>> [CLI] environments setup-local --serverless-version 4 --no-dbconnect --dry-run --output json +{ + "schemaVersion": 1, + "command": "environments setup-local", + "ok": true, + "mode": "constraints-only", + "dryRun": true, + "compute": { + "source": "serverless", + "serverlessVersion": "v4", + "envKey": "serverless/serverless-v4" + }, + "resolved": { + "pythonVersion": "3.12", + "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,17 @@\n [project]\n name = \"demo\"\n-requires-python = \"\u003e=3.10\"\n+requires-python = \"\u003e=3.12\"\n dependencies = [\"databricks-connect==15.1.*\"]\n \n [dependency-groups]\n dev = [\"databricks-connect~=16.0\"]\n+\n+[tool.databricks.environment]\n+environment_version = \"4\"\n+\n+# managed by databricks environments setup-local — do not edit\n+[tool.uv]\n+constraint-dependencies = [\n+ \"pyarrow\u003c19\",\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\" is replaced by the environment's \"\u003e=3.12\"" + } + ], + "error": null, + "durationMs": [DURATION_MS] +} diff --git a/acceptance/localenv/no-dbconnect/script b/acceptance/localenv/no-dbconnect/script new file mode 100644 index 00000000000..63b38e08faa --- /dev/null +++ b/acceptance/localenv/no-dbconnect/script @@ -0,0 +1,15 @@ +# --no-dbconnect is the orthogonal spelling of --constraints-only: it omits the +# databricks-connect dependency (mode "constraints-only") while still managing +# requires-python and the [tool.uv] constraints. Existing databricks-connect +# requirements the user already had are left untouched. +cat > pyproject.toml <<'PY' +[project] +name = "demo" +requires-python = ">=3.10" +dependencies = ["databricks-connect==15.1.*"] + +[dependency-groups] +dev = ["databricks-connect~=16.0"] +PY + +trace $CLI environments setup-local --serverless-version 4 --no-dbconnect --dry-run --output json diff --git a/acceptance/localenv/no-dbconnect/test.toml b/acceptance/localenv/no-dbconnect/test.toml new file mode 100644 index 00000000000..6377a54fca8 --- /dev/null +++ b/acceptance/localenv/no-dbconnect/test.toml @@ -0,0 +1,23 @@ +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +# The script writes pyproject.toml as the merge input; --dry-run leaves it unchanged. +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<19"] +''' + +[[Repls]] +Old = 'uv uv \S+(?: \([^)]+\))?' +New = 'uv [UV_VERSION]' diff --git a/acceptance/localenv/no-provision-dry-run/out.test.toml b/acceptance/localenv/no-provision-dry-run/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/localenv/no-provision-dry-run/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/localenv/no-provision-dry-run/output.txt b/acceptance/localenv/no-provision-dry-run/output.txt new file mode 100644 index 00000000000..a0dc1aafdc7 --- /dev/null +++ b/acceptance/localenv/no-provision-dry-run/output.txt @@ -0,0 +1,53 @@ + +>>> [CLI] environments setup-local --serverless-version 4 --no-provision --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": true, + "plan": { + "wouldWrite": "[TEST_TMP_DIR]/pyproject.toml", + "diff": "--- pyproject.toml\n+++ pyproject.toml\n@@ -1 +1,20 @@\n+[project]\n+name = \"001\"\n+version = \"0.0.0\"\n+requires-python = \"\u003e=3.12\"\n+\n+[dependency-groups]\n+dev = [\n+ \"databricks-connect~=17.2.0\",\n+]\n+\n+[tool.databricks.environment]\n+environment_version = \"4\"\n+\n+# managed by databricks environments setup-local — do not edit\n+[tool.uv]\n+constraint-dependencies = [\n+ \"pyarrow\u003c19\",\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": "skipped" + }, + { + "phase": "validate", + "status": "skipped" + } + ], + "warnings": [], + "error": null, + "durationMs": [DURATION_MS] +} diff --git a/acceptance/localenv/no-provision-dry-run/script b/acceptance/localenv/no-provision-dry-run/script new file mode 100644 index 00000000000..de74336fbf3 --- /dev/null +++ b/acceptance/localenv/no-provision-dry-run/script @@ -0,0 +1,4 @@ +# --dry-run --no-provision: nothing is provisioned (as with any dry run), but the +# plan reflects the real run's intent, so provision and validate report "skipped" +# rather than the "ok" a plain dry run reports. +trace $CLI environments setup-local --serverless-version 4 --no-provision --dry-run --output json diff --git a/acceptance/localenv/no-provision-dry-run/test.toml b/acceptance/localenv/no-provision-dry-run/test.toml new file mode 100644 index 00000000000..467508f4a03 --- /dev/null +++ b/acceptance/localenv/no-provision-dry-run/test.toml @@ -0,0 +1,20 @@ +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +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<19", "pandas<3"] +''' + +[[Repls]] +Old = 'uv uv \S+(?: \([^)]+\))?' +New = 'uv [UV_VERSION]' diff --git a/acceptance/localenv/no-provision-text/out.test.toml b/acceptance/localenv/no-provision-text/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/localenv/no-provision-text/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/localenv/no-provision-text/output.txt b/acceptance/localenv/no-provision-text/output.txt new file mode 100644 index 00000000000..f42fbf13dc6 --- /dev/null +++ b/acceptance/localenv/no-provision-text/output.txt @@ -0,0 +1,10 @@ + +>>> [CLI] environments setup-local --serverless-version 4 --no-provision +✔ Project files written (provisioning skipped) + + Compute target serverless 4 + Python 3.12 + databricks-connect 17.2.0 + pyproject.toml created + +No virtual environment was created (--no-provision). Re-run without --no-provision to create it. diff --git a/acceptance/localenv/no-provision-text/script b/acceptance/localenv/no-provision-text/script new file mode 100644 index 00000000000..0baf8a6d58f --- /dev/null +++ b/acceptance/localenv/no-provision-text/script @@ -0,0 +1,3 @@ +# Text-mode --no-provision: the files are written but no virtual environment is +# created, so the summary must not claim a venv or print activation hints for one. +trace $CLI environments setup-local --serverless-version 4 --no-provision diff --git a/acceptance/localenv/no-provision-text/test.toml b/acceptance/localenv/no-provision-text/test.toml new file mode 100644 index 00000000000..12ca584b223 --- /dev/null +++ b/acceptance/localenv/no-provision-text/test.toml @@ -0,0 +1,24 @@ +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +# A real --no-provision run creates pyproject.toml through the merge phase; the +# assertion is the JSON output, so ignore the written file. +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<19", "pandas<3"] +''' + +[[Repls]] +Old = 'uv uv \S+(?: \([^)]+\))?' +New = 'uv [UV_VERSION]' diff --git a/acceptance/localenv/no-provision/out.test.toml b/acceptance/localenv/no-provision/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/localenv/no-provision/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/localenv/no-provision/output.txt b/acceptance/localenv/no-provision/output.txt new file mode 100644 index 00000000000..9a120ccbd64 --- /dev/null +++ b/acceptance/localenv/no-provision/output.txt @@ -0,0 +1,49 @@ + +>>> [CLI] environments setup-local --serverless-version 4 --no-provision --output json +{ + "schemaVersion": 1, + "command": "environments setup-local", + "ok": true, + "mode": "default", + "dryRun": false, + "compute": { + "source": "serverless", + "serverlessVersion": "v4", + "envKey": "serverless/serverless-v4" + }, + "resolved": { + "pythonVersion": "3.12", + "dbconnectVersion": "17.2.0", + "artifactSource": "network" + }, + "greenfield": true, + "phases": [ + { + "phase": "preflight", + "status": "ok" + }, + { + "phase": "resolve", + "status": "ok" + }, + { + "phase": "fetch", + "status": "ok" + }, + { + "phase": "merge", + "status": "ok" + }, + { + "phase": "provision", + "status": "skipped" + }, + { + "phase": "validate", + "status": "skipped" + } + ], + "warnings": [], + "error": null, + "durationMs": [DURATION_MS] +} diff --git a/acceptance/localenv/no-provision/script b/acceptance/localenv/no-provision/script new file mode 100644 index 00000000000..d538ad5cc0e --- /dev/null +++ b/acceptance/localenv/no-provision/script @@ -0,0 +1,5 @@ +# --no-provision writes the project files (a real run, not a dry run) but stops +# before creating the venv: no Python download, uv sync, or validation. The +# provision and validate phases report "skipped" (distinct from "pending", which +# would mean an earlier phase failed) and venvPath is omitted. +trace $CLI environments setup-local --serverless-version 4 --no-provision --output json diff --git a/acceptance/localenv/no-provision/test.toml b/acceptance/localenv/no-provision/test.toml new file mode 100644 index 00000000000..12ca584b223 --- /dev/null +++ b/acceptance/localenv/no-provision/test.toml @@ -0,0 +1,24 @@ +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +# A real --no-provision run creates pyproject.toml through the merge phase; the +# assertion is the JSON output, so ignore the written file. +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<19", "pandas<3"] +''' + +[[Repls]] +Old = 'uv uv \S+(?: \([^)]+\))?' +New = 'uv [UV_VERSION]' diff --git a/cmd/environments/output.go b/cmd/environments/output.go index aa2d124f371..4b1935a11d9 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -80,19 +80,65 @@ func renderResult(ctx context.Context, cmd *cobra.Command, res *libslocalenv.Res return nil } + if provisionSkipped(res) { + renderProvisionSkippedSuccess(ctx, res) + return nil + } + renderSuccess(ctx, res) return nil } -// renderSuccess prints the friendly post-provision summary (DECO-27977). +// provisionSkipped reports whether the run wrote the project files but skipped +// creating the virtual environment (--no-provision), so the text summary must +// not claim a venv or print activation hints for one. +func provisionSkipped(res *libslocalenv.Result) bool { + for _, ph := range res.Phases { + if ph.Phase == libslocalenv.PhaseProvision { + return ph.Status == libslocalenv.StatusSkipped + } + } + return false +} + +// renderSuccess prints the friendly post-provision summary. // -// 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). +// It runs only on a non-dry-run success that actually provisioned (renderResult +// returns earlier for JSON, failures, dry runs, and --no-provision), so +// res.VenvPath is always set: the validate phase — the last thing such a 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, "") + renderComputeAndResolved(ctx, res) + cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "Virtual env", res.VenvPath)) + cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "pyproject.toml", pyprojectDetail(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") +} + +// renderProvisionSkippedSuccess prints the summary for a --no-provision run: the +// project files were written but no virtual environment was created. It omits the +// venv line and activation hints renderSuccess prints (res.VenvPath is empty +// here), and tells the user how to finish the setup. +func renderProvisionSkippedSuccess(ctx context.Context, res *libslocalenv.Result) { + cmdio.LogString(ctx, "✔ Project files written (provisioning skipped)") + cmdio.LogString(ctx, "") + + renderComputeAndResolved(ctx, res) + cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "pyproject.toml", pyprojectDetail(res))) + + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, "No virtual environment was created (--no-provision). Re-run without --no-provision to create it.") +} + +// renderComputeAndResolved prints the shared compute-target and resolved-version +// rows used by both the provisioned and --no-provision success summaries. +func renderComputeAndResolved(ctx context.Context, res *libslocalenv.Result) { if res.Compute != nil { cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "Compute target", res.Compute.Label())) } @@ -102,20 +148,19 @@ func renderSuccess(ctx context.Context, res *libslocalenv.Result) { cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "databricks-connect", res.Resolved.DBConnectVersion)) } } - 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") +// pyprojectDetail describes what happened to pyproject.toml: created (greenfield) +// or updated in place (noting the backup when one was written). +func pyprojectDetail(res *libslocalenv.Result) string { + switch { + case res.Greenfield: + return "created" + case res.BackupPath != "": + return "updated (backup: " + res.BackupPath + ")" + default: + return "updated" + } } // activateHint returns the shell command to activate the virtual environment, diff --git a/cmd/environments/sync.go b/cmd/environments/sync.go index 5505e6834cf..b7c6935c460 100644 --- a/cmd/environments/sync.go +++ b/cmd/environments/sync.go @@ -60,6 +60,12 @@ func addComputeFlags(cmd *cobra.Command) { cmd.Flags().String("serverless-version", "", "serverless version to use as the compute target (e.g. 5)") cmd.Flags().String("job-task", "", "job task to use as the compute target, as . (the task key is required)") cmd.Flags().Bool("constraints-only", false, "apply the Python version and constraints without adding the databricks-connect dependency") + // The negative flags (--no-constraints, --no-dbconnect, --no-provision) are + // orthogonal and compose. --no-dbconnect and the older --constraints-only are + // equivalent (both skip the databricks-connect dependency). + cmd.Flags().Bool("no-constraints", false, "skip writing the remote Python version and dependency constraints") + cmd.Flags().Bool("no-dbconnect", false, "skip adding the databricks-connect dependency") + cmd.Flags().Bool("no-provision", false, "write the project files but skip creating the virtual environment (no uv sync, Python download, or validation)") cmd.Flags().Bool("dry-run", false, "compute the plan without writing files or provisioning") // The mutual exclusivity of the target flags is enforced in the pipeline's // preflight (as E_USAGE) rather than via cmd.MarkFlagsMutuallyExclusive, so @@ -122,6 +128,9 @@ func runPipeline(cmd *cobra.Command) error { serverless, _ := cmd.Flags().GetString("serverless-version") jobTask, _ := cmd.Flags().GetString("job-task") constraintsOnly, _ := cmd.Flags().GetBool("constraints-only") + noConstraints, _ := cmd.Flags().GetBool("no-constraints") + noDBConnect, _ := cmd.Flags().GetBool("no-dbconnect") + noProvision, _ := cmd.Flags().GetBool("no-provision") check, _ := cmd.Flags().GetBool("dry-run") computeFlags := libslocalenv.ComputeFlags{ @@ -134,8 +143,10 @@ func runPipeline(cmd *cobra.Command) error { // preflight, so a conflict is reported as E_USAGE through the phase/JSON // contract rather than as a bare error here. + // --no-dbconnect is the orthogonal spelling of --constraints-only; either skips + // the databricks-connect dependency, which the pipeline models as the mode. mode := libslocalenv.ModeDefault - if constraintsOnly { + if constraintsOnly || noDBConnect { mode = libslocalenv.ModeConstraintsOnly } @@ -177,6 +188,8 @@ func runPipeline(cmd *cobra.Command) error { p := &libslocalenv.Pipeline{ Mode: mode, Check: check, + SkipConstraints: noConstraints, + SkipProvision: noProvision, ProjectDir: projectDir, ConstraintBaseURL: constraintBaseURL, CacheDir: cacheDir, diff --git a/libs/localenv/constraints.go b/libs/localenv/constraints.go index 36357facddb..5c3b9e64b0c 100644 --- a/libs/localenv/constraints.go +++ b/libs/localenv/constraints.go @@ -284,7 +284,15 @@ func parseConstraints(data []byte) (requiresPython, dbconnect string, deps []str } } + // Normalize a missing [tool.uv].constraint-dependencies to a non-nil empty + // slice. A nil ConstraintDeps is reserved as the --no-constraints "leave the + // constraint block unmanaged" signal (mergeToolUv and RenderFreshPyproject skip + // on nil); without this, an artifact that simply omits the key would be + // indistinguishable from the flag and would silently stop being managed. deps = p.Tool.UV.ConstraintDependencies + if deps == nil { + deps = []string{} + } return requiresPython, dbconnect, deps, nil } diff --git a/libs/localenv/constraints_test.go b/libs/localenv/constraints_test.go index 2590ce0c643..6eef3e639d6 100644 --- a/libs/localenv/constraints_test.go +++ b/libs/localenv/constraints_test.go @@ -259,3 +259,20 @@ func TestFetchConstraintsUnusableBodyDoesNotPoisonCache(t *testing.T) { assert.True(t, c.FromCache) assert.Equal(t, "==3.12.*", c.RequiresPython) } + +func TestParseConstraintsNormalizesMissingConstraintDepsToEmpty(t *testing.T) { + // An artifact without [tool.uv].constraint-dependencies yields a non-nil empty + // slice, not nil, so a nil ConstraintDeps is reserved as the --no-constraints + // "leave the constraint block unmanaged" signal (mergeToolUv / RenderFreshPyproject + // treat nil as skip). Without this, a normal artifact that simply omits the key + // would be indistinguishable from the flag. + _, _, deps, err := parseConstraints([]byte(`[project] +requires-python = ">=3.12" + +[dependency-groups] +dev = ["databricks-connect~=17.2.0"] +`)) + require.NoError(t, err) + require.NotNil(t, deps) + assert.Empty(t, deps) +} diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go index e89c05ccef1..78b8080b288 100644 --- a/libs/localenv/merge.go +++ b/libs/localenv/merge.go @@ -217,6 +217,15 @@ func tableBounds(lines []string, name string) (header, end int, found bool) { // the line's leading whitespace. If the key is absent, it is inserted directly under the // [project] header. Returns whether the line slice changed. func mergeRequiresPython(lines []string, value string) ([]string, bool) { + // An empty value means requires-python is unmanaged (--no-constraints): leave + // the user's line untouched rather than overwrite it with a blank pin. This + // mirrors how an empty DatabricksConnect / EnvironmentVersion is a no-op; a + // real fetched artifact always carries a requires-python, so empty only ever + // reaches here when the caller deliberately cleared it. + if value == "" { + return lines, false + } + header, end, found := tableBounds(lines, "[project]") if !found { return lines, false @@ -861,6 +870,15 @@ func arrayLineSpan(lines []string, start, limit int) (last int, multiline bool) // marker-bracketed block already exists, its contents are replaced in place. Otherwise any // plain [tool.uv] table is removed and a fresh marker-bracketed block is appended at EOF. func mergeToolUv(lines, deps []string) ([]string, bool) { + // A nil deps slice means the [tool.uv] constraint region is unmanaged + // (--no-constraints): leave any existing block untouched and write none. + // Distinct from a non-nil empty slice, which still renders an empty managed + // block; a real fetched artifact always carries constraint-dependencies, so + // nil only reaches here when the caller deliberately cleared it. + if deps == nil { + return lines, false + } + start, stop, found := markerBounds(lines) if found { // Replace the existing managed region in place. Whether it owns a [tool.uv] @@ -1232,7 +1250,11 @@ func RenderFreshPyproject(projectName string, c Constraints) []byte { fmt.Fprintf(&b, "name = %q\n", projectName) // uv requires project.version when a [project] table is present. fmt.Fprintf(&b, "version = %q\n", freshProjectVersion) - fmt.Fprintf(&b, "requires-python = %q\n", c.RequiresPython) + // requires-python is omitted when unmanaged (--no-constraints); an empty pin + // would be invalid, and a fresh project without it lets uv pick the interpreter. + if c.RequiresPython != "" { + fmt.Fprintf(&b, "requires-python = %q\n", c.RequiresPython) + } b.WriteString("\n") b.WriteString("[dependency-groups]\n") if c.DatabricksConnect != "" { @@ -1250,9 +1272,13 @@ func RenderFreshPyproject(projectName string, c Constraints) []byte { fmt.Fprintf(&b, "environment_version = %q\n", c.EnvironmentVersion) b.WriteString("\n") } - for _, line := range renderToolUvBlock(c.ConstraintDeps, true) { - b.WriteString(line) - b.WriteString("\n") + // The [tool.uv] constraint block is omitted when unmanaged (--no-constraints, + // signalled by a nil slice); a non-nil empty slice still renders an empty block. + if c.ConstraintDeps != nil { + for _, line := range renderToolUvBlock(c.ConstraintDeps, true) { + b.WriteString(line) + b.WriteString("\n") + } } return []byte(b.String()) } diff --git a/libs/localenv/merge_test.go b/libs/localenv/merge_test.go index 048b401d617..67f67a69778 100644 --- a/libs/localenv/merge_test.go +++ b/libs/localenv/merge_test.go @@ -1155,3 +1155,53 @@ constraint-dependencies = ["old~=1.0"] func countOccurrences(s, substr string) int { return strings.Count(s, substr) } + +func TestMergeManagedSkipsRequiresPythonWhenEmpty(t *testing.T) { + // An empty RequiresPython is the --no-constraints signal: the merge must leave + // the user's requires-python untouched rather than overwrite it with "". + in := []byte(`[project] +name = "demo" +requires-python = ">=3.9" + +[dependency-groups] +dev = [] +`) + c := testConstraints() + c.RequiresPython = "" + out, regions, err := MergeManaged(in, c) + require.NoError(t, err) + assert.Contains(t, string(out), `requires-python = ">=3.9"`) + assert.NotContains(t, regions, regionRequiresPython) +} + +func TestMergeManagedSkipsToolUvWhenNil(t *testing.T) { + // A nil ConstraintDeps is the --no-constraints signal: no managed [tool.uv] + // constraint block is written and the region is not reported. + in := []byte(`[project] +name = "demo" +requires-python = "==3.12.*" + +[dependency-groups] +dev = [] +`) + c := testConstraints() + c.ConstraintDeps = nil + out, regions, err := MergeManaged(in, c) + require.NoError(t, err) + assert.NotContains(t, string(out), "constraint-dependencies") + assert.NotContains(t, regions, regionToolUv) +} + +func TestRenderFreshPyprojectOmitsConstraintsWhenEmpty(t *testing.T) { + // Greenfield --no-constraints: neither the Python pin nor the [tool.uv] + // constraint block is rendered, but databricks-connect (orthogonal) still is. + c := testConstraints() + c.RequiresPython = "" + c.ConstraintDeps = nil + out := RenderFreshPyproject("demo", c) + s := string(out) + assert.NotContains(t, s, "requires-python") + assert.NotContains(t, s, "constraint-dependencies") + assert.Contains(t, s, `"databricks-connect~=17.2.0",`) + requireValidTOML(t, out) +} diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 258645aba30..bfa834ecb24 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -59,6 +59,16 @@ type Pipeline struct { Compute ComputeClient PM PackageManager + // SkipConstraints (--no-constraints) leaves the remote Python version and + // dependency pins unmanaged: the merge writes neither requires-python nor the + // [tool.uv] constraint block, and any existing values are left untouched. It is + // orthogonal to Mode (the databricks-connect axis). + SkipConstraints bool + // SkipProvision (--no-provision) writes the project files through the merge + // phase, then stops: no Python install, uv sync, or validation. The provision + // and validate phases report StatusSkipped and venvPath is omitted. + SkipProvision bool + // 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 @@ -172,9 +182,18 @@ func (p *Pipeline) run(ctx context.Context) error { // - PackageManager.EnsureAvailable may install the manager (uv) if missing. // Both exist to fail fast before real writes, which --dry-run never performs, so // they are skipped in a dry run. Neither result is needed to compute the plan. - if p.Check { + switch { + case p.Check: p.markOK(PhasePreflight, "check") - } else { + case p.SkipProvision: + // --no-provision writes files but never invokes uv, so it must not require + // or install it — a files-only run succeeds on a machine without uv. The + // project must still be writable, since the merge phase writes to it. + if err := ensureWritable(p.ProjectDir); err != nil { + return p.fail(PhasePreflight, false, NewError(ErrNotWritable, err, "project directory %s is not writable", filepath.ToSlash(p.ProjectDir))) + } + p.markOK(PhasePreflight, "no-provision") + default: if err := ensureWritable(p.ProjectDir); err != nil { return p.fail(PhasePreflight, false, NewError(ErrNotWritable, err, "project directory %s is not writable", filepath.ToSlash(p.ProjectDir))) } @@ -234,8 +253,7 @@ func (p *Pipeline) run(ctx context.Context) error { // Check mode stops after planning — nothing below mutates disk. if p.Check { p.markOK(PhaseMerge, "") - p.markOK(PhaseProvision, "") - p.markOK(PhaseValidate, "") + p.markProvisionAndValidate() return nil } @@ -245,6 +263,14 @@ func (p *Pipeline) run(ctx context.Context) error { } p.markOK(PhaseMerge, "") + // --no-provision writes the files but stops here: no Python install, uv sync, + // or validation. The provision and validate phases are skipped and venvPath is + // left empty (omitted), since no venv is created. + if p.SkipProvision { + p.markProvisionAndValidate() + return nil + } + // Phase: provision — ensure Python, run uv sync, seed pip. p.report(ctx, PhaseProvision) if err := p.provision(ctx, pyMinor); err != nil { @@ -384,13 +410,35 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, effective := *c effective.DatabricksConnect = dbcPin effective.EnvironmentVersion = envVersion + if p.SkipConstraints { + // --no-constraints: leave the remote Python version and dependency pins + // unmanaged. The empty/nil values signal both the merge (mergeRequiresPython, + // mergeToolUv) and the fresh render to skip those regions, leaving any + // existing values untouched. + // + // The flag governs only what is *written*. A provisioning run still installs + // and validates the resolved Python (pyMinor), so if the user's kept + // requires-python is disjoint from the target, uv surfaces it as a normal + // E_PROVISION rather than this command guessing an alternative. The + // --no-constraints --no-provision pairing skips provisioning entirely and + // avoids that tension. + effective.RequiresPython = "" + effective.ConstraintDeps = nil + } var changedRegions []string if greenfield { // No existing pyproject.toml — render a fresh one. The project name comes - // from the directory name as a reasonable default. + // from the directory name as a reasonable default. Only the regions actually + // rendered are reported (requires-python and tool.uv are omitted under + // --no-constraints). merged = RenderFreshPyproject(projectName(p.ProjectDir), effective) - changedRegions = []string{regionRequiresPython, regionToolUv} + if effective.RequiresPython != "" { + changedRegions = append(changedRegions, regionRequiresPython) + } + if effective.ConstraintDeps != nil { + changedRegions = append(changedRegions, regionToolUv) + } if dbcPin != "" { changedRegions = append(changedRegions, regionDatabricksConnect) } @@ -427,10 +475,14 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, diff := fmt.Sprint(gotextdiff.ToUnified(oldName, newName, oldStr, edits)) plan := &Plan{ - WouldWrite: filepath.ToSlash(pyproject), - Diff: diff, - ChangedRegions: changedRegions, - WouldInstallPython: pyMinor, + WouldWrite: filepath.ToSlash(pyproject), + Diff: diff, + ChangedRegions: changedRegions, + } + // --no-provision stops before installing Python, so the plan must not claim + // a Python install; every other path would provision it. + if !p.SkipProvision { + plan.WouldInstallPython = pyMinor } // Report a backup only when the run would actually write one (i.e. it changes // the file); a no-op re-run writes none. @@ -611,6 +663,33 @@ func (p *Pipeline) markOK(name PhaseName, detail string) { } } +// markSkipped marks a phase skipped: a flag opted out of running it. Unlike +// markOK it carries no detail — there is nothing to report about work not done — +// and unlike a pending phase it is a successful outcome, not a stopped one. +func (p *Pipeline) markSkipped(name PhaseName) { + for i := range p.res.Phases { + if p.res.Phases[i].Phase == name { + p.res.Phases[i].Status = StatusSkipped + p.res.Phases[i].Detail = "" + return + } + } +} + +// markProvisionAndValidate records the provision and validate outcomes for a run +// that stops before executing them. Under --no-provision they are skipped; under +// a plain --dry-run they are ok (the plan through merge succeeded and nothing +// below would run). It is the shared tail of both non-provisioning paths. +func (p *Pipeline) markProvisionAndValidate() { + if p.SkipProvision { + p.markSkipped(PhaseProvision) + p.markSkipped(PhaseValidate) + return + } + p.markOK(PhaseProvision, "") + p.markOK(PhaseValidate, "") +} + // fail marks the given phase as errored, attaches the error (with its phase and // disk-mutation flag) to the Result, and returns it. Phases after the failing // one remain pending. diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index fdf11c2322c..32b2735f9e4 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -1201,3 +1201,135 @@ func TestPipelineReportsPhaseStarts(t *testing.T) { // A full successful run enters every phase exactly once in canonical order. assert.Equal(t, allPhases, rep.started) } + +func TestPipelineNoProvisionWritesFilesButSkipsProvisionAndValidate(t *testing.T) { + dir := writeProject(t) + srv := newTestServer(t) + defer srv.Close() + + // --no-provision writes the project files (a real merge, not a dry run) but + // stops before creating the venv: provision and validate are "skipped". It also + // never invokes uv — not even the preflight availability probe — so a files-only + // run succeeds on a machine without uv. noProvisionPM fails every PackageManager + // method, so this passing proves none of them (EnsureAvailable included) is called. + p := &Pipeline{ + Mode: ModeDefault, SkipProvision: true, ProjectDir: dir, + ConstraintBaseURL: srv.URL, CacheDir: t.TempDir(), + Flags: ComputeFlags{Serverless: "v4"}, + Compute: stubCompute{}, PM: noProvisionPM{}, + } + res, err := p.Run(t.Context()) + require.NoError(t, err) + assert.True(t, res.OK) + // Files were written through the merge phase. + data, readErr := os.ReadFile(filepath.Join(dir, "pyproject.toml")) + require.NoError(t, readErr) + assert.Contains(t, string(data), `"databricks-connect~=17.2.0"`) + // Merge succeeded; provision and validate are skipped (distinct from pending, + // which would mean an earlier phase failed). + assert.Equal(t, StatusOK, phaseStatus(res, PhaseMerge)) + assert.Equal(t, StatusSkipped, phaseStatus(res, PhaseProvision)) + assert.Equal(t, StatusSkipped, phaseStatus(res, PhaseValidate)) + // No venv was provisioned, so venvPath is omitted. + assert.Empty(t, res.VenvPath) +} + +func TestPipelineNoProvisionUnderDryRunMarksProvisionValidateSkipped(t *testing.T) { + dir := writeProject(t) + srv := newTestServer(t) + defer srv.Close() + + // --dry-run --no-provision: nothing is provisioned (as with any dry run), but + // the plan reflects the real run's intent, so provision/validate are "skipped" + // rather than the "ok" a plain dry run reports. + p := &Pipeline{ + Mode: ModeDefault, Check: true, SkipProvision: true, ProjectDir: dir, + ConstraintBaseURL: srv.URL, CacheDir: t.TempDir(), + Flags: ComputeFlags{Serverless: "v4"}, + Compute: stubCompute{}, PM: noProvisionPM{}, + } + res, err := p.Run(t.Context()) + require.NoError(t, err) + assert.True(t, res.OK) + require.NotNil(t, res.Plan) + assert.Equal(t, StatusSkipped, phaseStatus(res, PhaseProvision)) + assert.Equal(t, StatusSkipped, phaseStatus(res, PhaseValidate)) + assert.Empty(t, res.VenvPath) +} + +func TestPipelineNoConstraintsLeavesExistingPinsUntouched(t *testing.T) { + dir := t.TempDir() + // An existing project with the user's own requires-python and no managed + // [tool.uv] constraint block. + require.NoError(t, os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(`[project] +name = "demo" +requires-python = ">=3.9" + +[dependency-groups] +dev = ["databricks-connect~=16.0.0"] +`), 0o644)) + srv := newTestServer(t) + defer srv.Close() + + p := &Pipeline{ + Mode: ModeDefault, SkipConstraints: 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.True(t, res.OK) + data, _ := os.ReadFile(filepath.Join(dir, "pyproject.toml")) + s := string(data) + // requires-python keeps the user's value; the artifact's ==3.12.* is not written. + assert.Contains(t, s, `requires-python = ">=3.9"`) + assert.NotContains(t, s, "==3.12.*") + // No managed [tool.uv] constraint-dependencies block is written. + assert.NotContains(t, s, "constraint-dependencies") + // databricks-connect is still managed: --no-constraints is orthogonal to it. + assert.Contains(t, s, "databricks-connect~=17.2.0") +} + +func TestPipelineNoConstraintsGreenfieldOmitsPins(t *testing.T) { + dir := t.TempDir() + srv := newTestServer(t) + defer srv.Close() + + p := &Pipeline{ + Mode: ModeDefault, SkipConstraints: 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.True(t, res.OK) + assert.True(t, res.Greenfield) + data, _ := os.ReadFile(filepath.Join(dir, "pyproject.toml")) + s := string(data) + // The artifact's Python pin and constraint-dependencies are not written. + assert.NotContains(t, s, "==3.12.*") + assert.NotContains(t, s, "constraint-dependencies") + // databricks-connect (orthogonal) is still added. + assert.Contains(t, s, "databricks-connect~=17.2.0") +} + +func TestPipelineNoProvisionDryRunPlanOmitsWouldInstallPython(t *testing.T) { + dir := writeProject(t) + srv := newTestServer(t) + defer srv.Close() + + // --no-provision would not install Python, so the dry-run plan must not claim + // it would (wouldInstallPython is omitted). + p := &Pipeline{ + Mode: ModeDefault, Check: true, SkipProvision: true, ProjectDir: dir, + ConstraintBaseURL: srv.URL, CacheDir: t.TempDir(), + Flags: ComputeFlags{Serverless: "v4"}, + Compute: stubCompute{}, PM: noProvisionPM{}, + } + res, err := p.Run(t.Context()) + require.NoError(t, err) + require.NotNil(t, res.Plan) + assert.Empty(t, res.Plan.WouldInstallPython) +} diff --git a/libs/localenv/result.go b/libs/localenv/result.go index 236d845d685..f6e164d89a2 100644 --- a/libs/localenv/result.go +++ b/libs/localenv/result.go @@ -21,7 +21,11 @@ const ( CommandName = CommandGroup + " " + CommandVerb // SchemaVersion is the version of the --json output contract (spec §6). - // Bump it on any breaking change to the JSON shape. + // Bump it on any breaking change to the JSON shape — the set of keys and their + // types. Adding a new value to an existing enum field is not such a change: it + // stays at 1. The "skipped" phase status is an example — it only appears when a + // new opt-in flag (--no-provision) is passed, so a default run's output is + // byte-for-byte unchanged and existing consumers see exactly what they did before. SchemaVersion = 1 ) @@ -68,6 +72,11 @@ const ( StatusOK = "ok" StatusError = "error" StatusPending = "pending" + // StatusSkipped marks a phase the run deliberately did not perform because a + // flag opted out of it (provision + validate under --no-provision). It is + // distinct from StatusPending, which means an earlier phase failed before this + // one could run: skipped is a successful outcome, pending is a stopped one. + StatusSkipped = "skipped" ) // ErrorCode is a stable failure-class identifier surfaced in --json error.code From c049d701d9820d1d4c34ed0a0e419ac355869161 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Tue, 1 Sep 2026 18:18:08 +0200 Subject: [PATCH 2/2] Deprecate setup-local --constraints-only in favour of --no-dbconnect *Why* `databricks environments setup-local` now has the orthogonal `--no-dbconnect` flag, which skips the databricks-connect dependency with identical behaviour to the older `--constraints-only`. Keeping two visible spellings for one behaviour is confusing, but `--constraints-only` may already live in users' scripts and CI, so it cannot simply be removed. *What* Mark `--constraints-only` deprecated via cobra's `MarkDeprecated`, which hides it from `--help` and prints a one-line stderr notice ("Flag --constraints-only has been deprecated, use --no-dbconnect instead") once per run when it is used. The flag stays defined and its behaviour is unchanged, so existing callers keep working; actual removal is a separate, later step. Adds a changelog fragment. *Verification* - New unit test asserts the flag stays defined but hidden with the deprecation notice. - Regenerated acceptance goldens: `--constraints-only` dropped from the `help` listing; the `constraints-only` and `constraints-only-existing` runs now show the stderr deprecation line (stdout JSON unchanged, schemaVersion still 1). - `gofmt`, `go vet ./cmd/environments`, `go test ./cmd/environments`, and `go build ./...` all clean. Co-authored-by: Isaac --- .../setup-local-deprecate-constraints-only.md | 1 + .../constraints-only-existing/output.txt | 1 + .../localenv/constraints-only/output.txt | 1 + acceptance/localenv/help/output.txt | 1 - cmd/environments/sync.go | 5 +++++ cmd/environments/sync_test.go | 21 +++++++++++++++++++ 6 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 .nextchanges/cli/setup-local-deprecate-constraints-only.md create mode 100644 cmd/environments/sync_test.go diff --git a/.nextchanges/cli/setup-local-deprecate-constraints-only.md b/.nextchanges/cli/setup-local-deprecate-constraints-only.md new file mode 100644 index 00000000000..c76e0e80ac2 --- /dev/null +++ b/.nextchanges/cli/setup-local-deprecate-constraints-only.md @@ -0,0 +1 @@ +Deprecated the `databricks environments setup-local --constraints-only` flag in favour of the orthogonal `--no-dbconnect`. The flag still works (it remains a hidden alias with identical behaviour) but is hidden from `--help` and now prints a one-line deprecation notice; it will be removed in a later release. diff --git a/acceptance/localenv/constraints-only-existing/output.txt b/acceptance/localenv/constraints-only-existing/output.txt index 365bc747bdc..68d94ccc50e 100644 --- a/acceptance/localenv/constraints-only-existing/output.txt +++ b/acceptance/localenv/constraints-only-existing/output.txt @@ -1,5 +1,6 @@ >>> [CLI] environments setup-local --serverless-version 4 --constraints-only --dry-run --output json +Flag --constraints-only has been deprecated, use --no-dbconnect instead { "schemaVersion": 1, "command": "environments setup-local", diff --git a/acceptance/localenv/constraints-only/output.txt b/acceptance/localenv/constraints-only/output.txt index 7fb5072d708..efcd88fd74e 100644 --- a/acceptance/localenv/constraints-only/output.txt +++ b/acceptance/localenv/constraints-only/output.txt @@ -1,5 +1,6 @@ >>> [CLI] environments setup-local --serverless-version 4 --constraints-only --dry-run --output json +Flag --constraints-only has been deprecated, use --no-dbconnect instead { "schemaVersion": 1, "command": "environments setup-local", diff --git a/acceptance/localenv/help/output.txt b/acceptance/localenv/help/output.txt index 7d455e00d42..b3831ec2384 100644 --- a/acceptance/localenv/help/output.txt +++ b/acceptance/localenv/help/output.txt @@ -18,7 +18,6 @@ Examples: 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) - --constraints-only apply the Python version and constraints without adding the databricks-connect dependency --dry-run compute the plan without writing files or provisioning -h, --help help for setup-local --job-task string job task to use as the compute target, as . (the task key is required) diff --git a/cmd/environments/sync.go b/cmd/environments/sync.go index b7c6935c460..6db6c0e3d4c 100644 --- a/cmd/environments/sync.go +++ b/cmd/environments/sync.go @@ -60,6 +60,11 @@ func addComputeFlags(cmd *cobra.Command) { cmd.Flags().String("serverless-version", "", "serverless version to use as the compute target (e.g. 5)") cmd.Flags().String("job-task", "", "job task to use as the compute target, as . (the task key is required)") cmd.Flags().Bool("constraints-only", false, "apply the Python version and constraints without adding the databricks-connect dependency") + // --constraints-only is superseded by the orthogonal --no-dbconnect (identical + // behaviour). Keep it defined so existing scripts and CI keep working, but hide + // it from --help and emit a one-line deprecation notice on stderr when it is + // used. MarkDeprecated does both; removal is a separate, later step. + cmd.Flags().MarkDeprecated("constraints-only", "use --no-dbconnect instead") // The negative flags (--no-constraints, --no-dbconnect, --no-provision) are // orthogonal and compose. --no-dbconnect and the older --constraints-only are // equivalent (both skip the databricks-connect dependency). diff --git a/cmd/environments/sync_test.go b/cmd/environments/sync_test.go new file mode 100644 index 00000000000..4c84c898090 --- /dev/null +++ b/cmd/environments/sync_test.go @@ -0,0 +1,21 @@ +package environments + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSetupLocalConstraintsOnlyDeprecated pins --constraints-only as a hidden, +// deprecated alias for --no-dbconnect: the flag is kept defined so scripts and CI +// that already pass it keep working, but it is hidden from --help and pflag prints +// a one-line deprecation notice pointing at --no-dbconnect when it is used. +func TestSetupLocalConstraintsOnlyDeprecated(t *testing.T) { + cmd := newSetupLocalCommand() + + f := cmd.Flags().Lookup("constraints-only") + require.NotNil(t, f, "--constraints-only must remain defined for backward compatibility") + assert.True(t, f.Hidden, "--constraints-only should be hidden from --help") + assert.Equal(t, "use --no-dbconnect instead", f.Deprecated, "--constraints-only should carry the deprecation notice") +}