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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nextchanges/cli/setup-local-uv-project-environment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* `databricks environments setup-local` now uses the environment in `UV_PROJECT_ENVIRONMENT` when it is set, as `uv sync` does. ([#7026](https://github.com/databricks/cli/pull/7026))
4 changes: 4 additions & 0 deletions acceptance/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,10 @@ func testAccept(t *testing.T, inprocessMode bool, selectedTests []string, skipTo
os.Unsetenv(v) //nolint:usetesting // t.Setenv cannot unset
}

// environments setup-local follows uv's UV_PROJECT_ENVIRONMENT, so a value
// from the host shell would move the venv the localenv tests expect.
os.Unsetenv("UV_PROJECT_ENVIRONMENT") //nolint:usetesting // t.Setenv cannot unset

if !requirePrerequisites(t) {
// Don't run the suite against a stale toolchain; the failed subtest
// has already marked the parent test as failed.
Expand Down
2 changes: 2 additions & 0 deletions acceptance/localenv/help/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Use this when you want to develop or debug a Databricks project locally: it inst

In a uv workspace member, the command uses the shared .venv at the workspace root and writes the dependency constraints to the root pyproject.toml, because uv reads them only from there. uv sync installs the current member's dependencies into the shared .venv and removes packages that only other members need. All members share one environment, so they must target compatible compute.

If UV_PROJECT_ENVIRONMENT is set, the command uses that environment instead of .venv, as uv does.

Usage:
databricks environments setup-local [flags]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[project]
name = "a"
version = "0.1.0"
requires-python = ">=3.10"
26 changes: 26 additions & 0 deletions acceptance/localenv/workspace-member-project-env/fake-uv.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/bash
# The workspace root is the test folder, where this script is installed as uv.
# Like real uv, `uv sync` creates the environment at UV_PROJECT_ENVIRONMENT
# (relative to the workspace root), with a fake interpreter that answers the
# validate probe (prefixes from libs/localenv/uv.go).
root="$(cd "$(dirname "$0")" && pwd)"
venv="$root/${UV_PROJECT_ENVIRONMENT:-.venv}"
case "$1 $2" in
"--version ") echo "uv 0.12.9 (fake)"; exit 0 ;;
"workspace dir") echo "$root"; exit 0 ;;
"python install") exit 0 ;;
"pip install") exit 0 ;;
"sync --python")
mkdir -p "$venv/bin"
cat > "$venv/bin/python" <<'PY'
#!/bin/bash
echo "PYVER:3.12"
echo "DBCVER:17.2.0"
echo "PYSPARKVER:"
echo "DBCIMPORT:"
PY
chmod +x "$venv/bin/python"
exit 0 ;;
esac
echo "fake uv: unexpected call: $*" >&2
exit 1
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Cloud = false
GOOS.windows = false
16 changes: 16 additions & 0 deletions acceptance/localenv/workspace-member-project-env/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@

>>> [CLI] environments setup-local --serverless-version 4
warning: requires-python ">=3.10" is replaced by the environment's ">=3.12"
✔ Local environment ready

Compute target serverless 4
Python 3.12
databricks-connect 17.2.0
Virtual env ../../custom env
pyproject.toml updated (backup: [TEST_TMP_DIR]/bundles/a/pyproject.toml.bak)
uv workspace root [TEST_TMP_DIR]
Root pyproject.toml constraints updated (backup: [TEST_TMP_DIR]/pyproject.toml.bak)

Next steps:
• Activate it: source '../../custom env/bin/activate'
• Or select ../../custom env as the Python interpreter in VS Code / Cursor
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[project]
name = "root"
version = "0.1.0"
requires-python = ">=3.10"

[tool.uv.workspace]
members = ["bundles/*"]
14 changes: 14 additions & 0 deletions acceptance/localenv/workspace-member-project-env/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# With UV_PROJECT_ENVIRONMENT set, the shared environment is that folder
# (relative to the workspace root), not .venv. The name holds a space, so the
# activate hint quotes it. A fake uv on PATH stands in for the
# network-dependent sync (see acceptance/cmd/psql).
mv fake-uv.sh uv
chmod +x uv
cleanup() {
rm -f uv
}
trap cleanup EXIT
export PATH="$(pwd):$PATH"
export UV_PROJECT_ENVIRONMENT="custom env"

withdir bundles/a trace $CLI environments setup-local --serverless-version 4
22 changes: 22 additions & 0 deletions acceptance/localenv/workspace-member-project-env/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# The fake uv is a bash script, so this test cannot run on Windows runners
# (mirrors acceptance/cmd/psql).
GOOS.windows = false

# The run writes both pyproject.toml files, their backups, and the shared
# environment "custom env" at the workspace root.
Ignore = ["custom env", "pyproject.toml.bak", "bundles/a/pyproject.toml.bak"]

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"]
'''
19 changes: 17 additions & 2 deletions cmd/environments/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/flags"
libslocalenv "github.com/databricks/cli/libs/localenv"
"github.com/databricks/cli/libs/shellquote"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -155,9 +156,23 @@ func activateHint(venvPath string) string {
if runtime.GOOS == "windows" {
// venvPath is in slash form; cmd.exe reads "/" as a switch character, so a
// relative path such as ../../.venv needs backslashes.
return filepath.FromSlash(venvPath) + `\Scripts\activate`
return quoteForCmd(filepath.FromSlash(venvPath) + `\Scripts\activate`)
}
return "source " + venvPath + "/bin/activate"
// A leading dash would be read as an option of source.
if strings.HasPrefix(venvPath, "-") {
venvPath = "./" + venvPath
}
return "source " + shellquote.BashArg(venvPath+"/bin/activate")
}

// quoteForCmd wraps path in double quotes when cmd.exe would otherwise split
// it at a space or a command separator (UV_PROJECT_ENVIRONMENT can name such a
// folder). Quotes do not stop %VAR% expansion; such folder names are rare.
func quoteForCmd(path string) string {
if strings.ContainsAny(path, " \t&^()<>|,;=!") {
return `"` + path + `"`
}
return path
}

// failureClause maps the failing phase to a human clause for the failure line,
Expand Down
13 changes: 13 additions & 0 deletions cmd/environments/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,19 @@ func TestActivateHintWorkspaceMember(t *testing.T) {
}
}

func TestActivateHintQuotesShellCharacters(t *testing.T) {
// UV_PROJECT_ENVIRONMENT can name a folder with spaces or shell characters.
if runtime.GOOS == "windows" {
assert.Equal(t, `"my env\Scripts\activate"`, activateHint("my env"))
assert.Equal(t, `"R&D\Scripts\activate"`, activateHint("R&D"))
return
}
assert.Equal(t, `source 'my env/bin/activate'`, activateHint("my env"))
assert.Equal(t, `source '/tmp/env$1/bin/activate'`, activateHint("/tmp/env$1"))
// A leading dash would be read as an option.
assert.Equal(t, `source ./-env/bin/activate`, activateHint("-env"))
}

func TestActivateHint(t *testing.T) {
hint := activateHint(".venv")
if runtime.GOOS == "windows" {
Expand Down
4 changes: 3 additions & 1 deletion cmd/environments/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ func newSetupLocalCommand() *cobra.Command {

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.

In a uv workspace member, the command uses the shared .venv at the workspace root and writes the dependency constraints to the root pyproject.toml, because uv reads them only from there. uv sync installs the current member's dependencies into the shared .venv and removes packages that only other members need. All members share one environment, so they must target compatible compute.`,
In a uv workspace member, the command uses the shared .venv at the workspace root and writes the dependency constraints to the root pyproject.toml, because uv reads them only from there. uv sync installs the current member's dependencies into the shared .venv and removes packages that only other members need. All members share one environment, so they must target compatible compute.

If UV_PROJECT_ENVIRONMENT is set, the command uses that environment instead of .venv, as uv does.`,
Example: ` # Match a serverless version
databricks environments setup-local --serverless-version 5

Expand Down
14 changes: 14 additions & 0 deletions libs/localenv/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package localenv

import (
"os"
"testing"
)

// TestMain clears uv's UV_PROJECT_ENVIRONMENT from the host shell: it moves the
// venv the pipeline uses, and most tests expect the default .venv. Tests that
// need it set it on the context.
func TestMain(m *testing.M) {
os.Unsetenv(EnvUvProjectEnvironment) //nolint:usetesting // t.Setenv cannot unset
m.Run()
}
66 changes: 56 additions & 10 deletions libs/localenv/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"

"github.com/databricks/cli/libs/atomicfile"
"github.com/databricks/cli/libs/env"
"github.com/databricks/cli/libs/log"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
Expand Down Expand Up @@ -77,8 +79,9 @@ type Pipeline struct {
// inspects. Set in preflight.
venv string

// venvRel is the venv folder relative to ProjectDir, reported as
// Result.VenvPath. Set in preflight.
// venvRel is the venv folder relative to ProjectDir (absolute when
// UV_PROJECT_ENVIRONMENT is absolute), reported as Result.VenvPath. Set in
// preflight.
venvRel string

// workspaceRoot is the uv workspace root when ProjectDir is a workspace
Expand Down Expand Up @@ -217,18 +220,18 @@ func (p *Pipeline) run(ctx context.Context) error {
return p.fail(PhasePreflight, false, NewError(ErrCanceled, err, "interrupted"))
}
venvBase := p.ProjectDir
p.venvRel = venvDir
if p.workspaceRoot != "" {
venvBase = p.workspaceRoot
p.venvRel = filepath.Join(rootRel, venvDir)
p.res.UvWorkspaceRoot = filepath.ToSlash(p.workspaceRoot)
if !p.Check {

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.

[medium] ensureWritable(workspaceRoot) can reject a run that never writes to the root.

For a workspace-member run with --no-constraints and an out-of-root UV_PROJECT_ENVIRONMENT, planRootMerge returns nil (SkipConstraints) so the root pyproject.toml is never written, and uv sync creates the venv at the out-of-root path — yet a read-only workspace root fails preflight with ErrNotWritable though nothing would be written there. Consider gating this check on whether the root is actually going to be written.

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.

The root must stay writable in this case too: uv sync creates or updates uv.lock at the workspace root, also with --no-constraints and an out-of-root venv. So the preflight check catches a failure that uv sync would hit later.

if err := ensureWritable(p.workspaceRoot); err != nil {
return p.fail(PhasePreflight, false, NewError(ErrNotWritable, err, "uv workspace root %s is not writable", filepath.ToSlash(p.workspaceRoot)))
}
}
}
p.venv = filepath.Join(venvBase, venvDir)
if err := p.setVenv(ctx, venvBase, rootRel); err != nil {
return err
}
p.markOK(PhasePreflight, detail)

// Phase: resolve — compute target → environment key.
Expand Down Expand Up @@ -326,6 +329,49 @@ func (p *Pipeline) run(ctx context.Context) error {
return p.validate(ctx, pyMinor, c.DatabricksConnect, opts)
}

// setVenv sets the venv folder where uv sync puts the project environment:
// UV_PROJECT_ENVIRONMENT when set, else .venv in venvBase (the workspace root
// or ProjectDir). rootRel is venvBase relative to the symlink-resolved
// ProjectDir ("" outside a workspace; see discoverWorkspaceRoot).
func (p *Pipeline) setVenv(ctx context.Context, venvBase, rootRel string) error {
name := env.Get(ctx, EnvUvProjectEnvironment)
switch {
case name == "":
p.venv = filepath.Join(venvBase, venvDir)
p.venvRel = filepath.Join(rootRel, venvDir)
case filepath.IsAbs(name):
// uv uses an absolute value as is. It is reported as is too: a relative
// path cannot cross drives.
p.venv = filepath.Clean(name)
p.venvRel = p.venv
case filepath.VolumeName(name) != "":
// A Windows drive-relative value such as C:envs\x names a folder on that
// drive; uv uses it as is.
p.venv = filepath.Clean(name)
p.venvRel = p.venv
case strings.HasPrefix(filepath.ToSlash(name), "/"):
// A Windows rooted path such as \envs\x: uv keeps only the drive of the
// root for it.
p.venv = filepath.Clean(filepath.VolumeName(venvBase) + name)
p.venvRel = p.venv
default:
// uv joins a relative value to its working folder. On Unix that folder is
// symlink-resolved, so ".." in the value climbs the real folders; Windows
// keeps the path as given and resolves ".." by text.
base := venvBase
if runtime.GOOS != "windows" {
resolved, err := filepath.EvalSymlinks(venvBase)
if err != nil {
return p.fail(PhasePreflight, false, NewError(ErrNotWritable, err, "cannot resolve %s to place %s=%s", filepath.ToSlash(venvBase), EnvUvProjectEnvironment, filepath.ToSlash(name)))

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.

[medium] EvalSymlinks failure misclassified as ErrNotWritable.

A relative UV_PROJECT_ENVIRONMENT whose venvBase has a broken/dangling symlink component makes EvalSymlinks fail here, but the run fails with code ErrNotWritable. A --output json consumer that branches on the error code (e.g. the extension recovery flow) will tell the user to fix write permissions when the real problem is an unresolvable path. Consider a distinct path-resolution code rather than ErrNotWritable.

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.

Keeping E_NOT_WRITABLE here. venvBase is always a folder that exists: the project folder, or the root that uv workspace dir returned. So a dangling symlink cannot reach this branch; the realistic failure is a permission error on a parent folder, which E_NOT_WRITABLE describes.

}
base = resolved
}
p.venv = filepath.Join(base, name)
p.venvRel = filepath.Join(rootRel, name)
}
return nil
}

// resolve runs ResolveCompute and records the resolve phase.
func (p *Pipeline) resolve(ctx context.Context) (*ComputeInfo, error) {
compute, err := ResolveCompute(ctx, p.Flags, p.Compute, p.Bundle)
Expand Down Expand Up @@ -751,11 +797,11 @@ func (p *Pipeline) validate(ctx context.Context, expectedPyMinor, dbcPin string,
}
p.markOK(PhaseValidate, detail)

// venvPath is reported relative to the project root (spec §6.1), not as an
// absolute path: the VS Code consumer already knows the project root (it sets
// the working directory when it shells out). For a standalone project this is
// ".venv"; for a workspace member it is computed between the symlink-resolved
// folders (see discoverWorkspaceRoot), for example "../../.venv".
// venvPath is reported relative to the project root (spec §6.1): the VS Code
// consumer already knows the project root (it sets the working directory
// when it shells out). By default it is ".venv", or a path such as
// "../../.venv" for a workspace member (see discoverWorkspaceRoot); it is
// absolute only when UV_PROJECT_ENVIRONMENT is absolute.
p.res.VenvPath = filepath.ToSlash(p.venvRel)
if p.res.Resolved != nil {
if managed {
Expand Down
Loading
Loading