diff --git a/.nextchanges/cli/setup-local-uv-project-environment.md b/.nextchanges/cli/setup-local-uv-project-environment.md new file mode 100644 index 00000000000..53da981295c --- /dev/null +++ b/.nextchanges/cli/setup-local-uv-project-environment.md @@ -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)) diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index def7c16c6d4..fb1013a424a 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -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. diff --git a/acceptance/localenv/help/output.txt b/acceptance/localenv/help/output.txt index 98abac079af..836ebb00d41 100644 --- a/acceptance/localenv/help/output.txt +++ b/acceptance/localenv/help/output.txt @@ -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] diff --git a/acceptance/localenv/workspace-member-project-env/bundles/a/pyproject.toml b/acceptance/localenv/workspace-member-project-env/bundles/a/pyproject.toml new file mode 100644 index 00000000000..962b088a2b0 --- /dev/null +++ b/acceptance/localenv/workspace-member-project-env/bundles/a/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "a" +version = "0.1.0" +requires-python = ">=3.10" diff --git a/acceptance/localenv/workspace-member-project-env/fake-uv.sh b/acceptance/localenv/workspace-member-project-env/fake-uv.sh new file mode 100644 index 00000000000..5f52746a189 --- /dev/null +++ b/acceptance/localenv/workspace-member-project-env/fake-uv.sh @@ -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 diff --git a/acceptance/localenv/workspace-member-project-env/out.test.toml b/acceptance/localenv/workspace-member-project-env/out.test.toml new file mode 100644 index 00000000000..2e1543aa802 --- /dev/null +++ b/acceptance/localenv/workspace-member-project-env/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +GOOS.windows = false diff --git a/acceptance/localenv/workspace-member-project-env/output.txt b/acceptance/localenv/workspace-member-project-env/output.txt new file mode 100644 index 00000000000..a5366a75bdf --- /dev/null +++ b/acceptance/localenv/workspace-member-project-env/output.txt @@ -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 diff --git a/acceptance/localenv/workspace-member-project-env/pyproject.toml b/acceptance/localenv/workspace-member-project-env/pyproject.toml new file mode 100644 index 00000000000..7a29f61f24a --- /dev/null +++ b/acceptance/localenv/workspace-member-project-env/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "root" +version = "0.1.0" +requires-python = ">=3.10" + +[tool.uv.workspace] +members = ["bundles/*"] diff --git a/acceptance/localenv/workspace-member-project-env/script b/acceptance/localenv/workspace-member-project-env/script new file mode 100644 index 00000000000..a9148f5f42f --- /dev/null +++ b/acceptance/localenv/workspace-member-project-env/script @@ -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 diff --git a/acceptance/localenv/workspace-member-project-env/test.toml b/acceptance/localenv/workspace-member-project-env/test.toml new file mode 100644 index 00000000000..c896c0fc247 --- /dev/null +++ b/acceptance/localenv/workspace-member-project-env/test.toml @@ -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"] +''' diff --git a/cmd/environments/output.go b/cmd/environments/output.go index f58a72b56ea..51851497eb7 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -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" ) @@ -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, diff --git a/cmd/environments/output_test.go b/cmd/environments/output_test.go index 05efa5a0305..2349402dbb7 100644 --- a/cmd/environments/output_test.go +++ b/cmd/environments/output_test.go @@ -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" { diff --git a/cmd/environments/sync.go b/cmd/environments/sync.go index b4e40474a03..ae10ae08eb3 100644 --- a/cmd/environments/sync.go +++ b/cmd/environments/sync.go @@ -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 diff --git a/libs/localenv/main_test.go b/libs/localenv/main_test.go new file mode 100644 index 00000000000..0eedb71d371 --- /dev/null +++ b/libs/localenv/main_test.go @@ -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() +} diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index aa64acacb07..99506128210 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -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" @@ -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 @@ -217,10 +220,8 @@ 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 { if err := ensureWritable(p.workspaceRoot); err != nil { @@ -228,7 +229,9 @@ func (p *Pipeline) run(ctx context.Context) error { } } } - 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. @@ -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))) + } + 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) @@ -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 { diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index e1c2e735e28..d08a3696069 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/process" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1527,3 +1528,143 @@ func TestPyprojectDiffLabels(t *testing.T) { assert.Contains(t, g, "+++ pyproject.toml") assert.NotContains(t, g, ".new") } + +// resolvedPath returns path with symlinks resolved (macOS temp folders live +// under /var, a symlink to /private/var). +func resolvedPath(t *testing.T, path string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(path) + require.NoError(t, err) + return resolved +} + +// uvJoinBase returns the folder uv joins a relative UV_PROJECT_ENVIRONMENT to: +// symlink-resolved on Unix, as given on Windows. +func uvJoinBase(t *testing.T, base string) string { + t.Helper() + if runtime.GOOS == "windows" { + return base + } + return resolvedPath(t, base) +} + +func TestPipelineUsesUvProjectEnvironment(t *testing.T) { + // uv puts the project environment at UV_PROJECT_ENVIRONMENT: an absolute + // path as is, a relative one against the (symlink-resolved) workspace root, + // or the project root outside a workspace. + abs := filepath.Join(t.TempDir(), "envs", "proj") + tests := []struct { + name string + // setup returns the project folder, the folder a relative value is + // joined to, and the fake package manager. + setup func(t *testing.T) (projectDir, base string, pm fakePM) + value string + wantVenv func(t *testing.T, base string) string + wantVenvPath func(venv string) string + }{ + { + name: "relative, standalone", + setup: func(t *testing.T) (string, string, fakePM) { + dir := writeProject(t) + return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} + }, + value: "custom-env", + wantVenv: func(t *testing.T, base string) string { return filepath.Join(uvJoinBase(t, base), "custom-env") }, + wantVenvPath: func(string) string { return "custom-env" }, + }, + { + name: "relative, workspace member", + setup: func(t *testing.T) (string, string, fakePM) { + root, member := memberLayout(t) + require.NoError(t, os.WriteFile(filepath.Join(member, pyprojectFile), + []byte("[project]\nname = \"a\"\nversion = \"0.1.0\"\nrequires-python = \">=3.10\"\n"), 0o644)) + return member, root, fakePM{py: "3.12", dbc: "17.2.0", root: root} + }, + value: "custom-env", + wantVenv: func(t *testing.T, base string) string { return filepath.Join(uvJoinBase(t, base), "custom-env") }, + wantVenvPath: func(string) string { return "../../custom-env" }, + }, + { + name: "relative with .. through a symlinked project", + setup: func(t *testing.T) (string, string, fakePM) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need extra privileges on Windows") + } + real := writeProject(t) + link := filepath.Join(t.TempDir(), "link") + require.NoError(t, os.Symlink(real, link)) + return link, real, fakePM{py: "3.12", dbc: "17.2.0"} + }, + value: filepath.Join("..", "env"), + wantVenv: func(t *testing.T, base string) string { + return filepath.Join(filepath.Dir(resolvedPath(t, base)), "env") + }, + wantVenvPath: func(string) string { return "../env" }, + }, + { + name: "relative with a trailing slash", + setup: func(t *testing.T) (string, string, fakePM) { + dir := writeProject(t) + return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} + }, + value: "custom-env/", + wantVenv: func(t *testing.T, base string) string { return filepath.Join(uvJoinBase(t, base), "custom-env") }, + wantVenvPath: func(string) string { return "custom-env" }, + }, + { + // On Windows a rooted path keeps the drive of the root, as in uv. + name: "rooted without a drive", + setup: func(t *testing.T) (string, string, fakePM) { + if runtime.GOOS != "windows" { + t.Skip("a rooted path without a drive exists only on Windows") + } + dir := writeProject(t) + return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} + }, + value: `\envs\x`, + wantVenv: func(t *testing.T, base string) string { return filepath.VolumeName(base) + `\envs\x` }, + // The drive comes from the project. + wantVenvPath: filepath.ToSlash, + }, + { + // A Windows drive-relative value such as C:envs\x names a folder on + // that drive; uv uses it as is, so does the CLI. + name: "drive-relative", + setup: func(t *testing.T) (string, string, fakePM) { + if runtime.GOOS != "windows" { + t.Skip("a drive-relative path exists only on Windows") + } + dir := writeProject(t) + return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} + }, + value: `C:envs\x`, + wantVenv: func(*testing.T, string) string { return `C:envs\x` }, + wantVenvPath: func(string) string { return "C:envs/x" }, + }, + { + name: "absolute", + setup: func(t *testing.T) (string, string, fakePM) { + dir := writeProject(t) + return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} + }, + value: abs, + wantVenv: func(*testing.T, string) string { return abs }, + wantVenvPath: func(string) string { return filepath.ToSlash(abs) }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + projectDir, base, fake := tc.setup(t) + pm := &venvRecordingPM{fakePM: fake} + p := newServerlessTestPipeline(t, projectDir) + p.PM = pm + + res, err := p.Run(env.Set(t.Context(), EnvUvProjectEnvironment, tc.value)) + require.NoError(t, err) + want := tc.wantVenv(t, base) + assert.Equal(t, want, pm.postProvisionVenv) + assert.Equal(t, want, pm.validateVenv) + assert.Equal(t, tc.wantVenvPath(want), res.VenvPath) + }) + } +} diff --git a/libs/localenv/provision_integration_test.go b/libs/localenv/provision_integration_test.go index c3e648bffc0..ba26615b0ab 100644 --- a/libs/localenv/provision_integration_test.go +++ b/libs/localenv/provision_integration_test.go @@ -107,18 +107,20 @@ func TestProvisionCreatesAndValidatesVenv(t *testing.T) { "expected databricks-connect major 17, got %q", res.Resolved.DBConnectVersion) } -// TestProvisionWorkspaceMember runs the real pipeline from a member of a uv -// workspace with two members. uv sync creates the shared venv at the workspace -// root, the constraint block lands in the root pyproject.toml, and uv applies it -// (the lock honors the artifact's pyarrow<19 pin, a databricks-connect -// dependency). -func TestProvisionWorkspaceMember(t *testing.T) { - requireRealProvision(t) +// requireUvWorkspaceDir skips unless uv has `uv workspace dir` (uv 0.9.9+). +func requireUvWorkspaceDir(t *testing.T) { + t.Helper() if _, err := process.Background(t.Context(), []string{mustUv(t, t.Context()), "workspace", "dir", "--help"}); err != nil { t.Skipf("uv has no `workspace dir` command (added in uv 0.9.9): %v", err) } +} + +// writeUvWorkspace creates a uv workspace with members bundles/ and +// returns its root. +func writeUvWorkspace(t *testing.T, members ...string) string { + t.Helper() root := t.TempDir() - for _, m := range []string{"a", "b"} { + for _, m := range members { dir := filepath.Join(root, "bundles", m) require.NoError(t, os.MkdirAll(dir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(dir, pyprojectFile), @@ -126,6 +128,18 @@ func TestProvisionWorkspaceMember(t *testing.T) { } require.NoError(t, os.WriteFile(filepath.Join(root, pyprojectFile), []byte("[project]\nname = \"root\"\nversion = \"0.1.0\"\nrequires-python = \">=3.10\"\n\n[tool.uv.workspace]\nmembers = [\"bundles/*\"]\n"), 0o644)) + return root +} + +// TestProvisionWorkspaceMember runs the real pipeline from a member of a uv +// workspace with two members. uv sync creates the shared venv at the workspace +// root, the constraint block lands in the root pyproject.toml, and uv applies it +// (the lock honors the artifact's pyarrow<19 pin, a databricks-connect +// dependency). +func TestProvisionWorkspaceMember(t *testing.T) { + requireRealProvision(t) + requireUvWorkspaceDir(t) + root := writeUvWorkspace(t, "a", "b") member := filepath.Join(root, "bundles", "a") srv := serverlessConstraintServer(t) @@ -154,6 +168,29 @@ func TestProvisionWorkspaceMember(t *testing.T) { assert.Less(t, major, 19, "the root constraint pyarrow<19 must apply") } +// TestProvisionWorkspaceMemberProjectEnvironment runs the real pipeline from a +// workspace member with a relative UV_PROJECT_ENVIRONMENT, which uv resolves +// against the workspace root. +func TestProvisionWorkspaceMemberProjectEnvironment(t *testing.T) { + requireRealProvision(t) + requireUvWorkspaceDir(t) + root := writeUvWorkspace(t, "a") + member := filepath.Join(root, "bundles", "a") + srv := serverlessConstraintServer(t) + + p := &Pipeline{ + Mode: ModeDefault, Check: false, ProjectDir: member, + ConstraintBaseURL: srv.URL, CacheDir: t.TempDir(), + Flags: ComputeFlags{Serverless: "5"}, + Compute: stubCompute{}, PM: NewUvManager(), + } + res, err := p.Run(env.Set(realProvisionCtx(t), EnvUvProjectEnvironment, "custom-env")) + require.NoError(t, err) + assert.Equal(t, "../../custom-env", res.VenvPath) + assert.FileExists(t, venvPython(filepath.Join(root, "custom-env"))) + assert.NoDirExists(t, filepath.Join(root, venvDir)) +} + // TestProvisionValidateIgnoresActiveVirtualEnv is the regression guard for the // validate fix: an active VIRTUAL_ENV pointing at a *real* interpreter of a // different version must not make validation read that interpreter instead of diff --git a/libs/localenv/result.go b/libs/localenv/result.go index 2ceb7dc6bbc..caf4e123ffd 100644 --- a/libs/localenv/result.go +++ b/libs/localenv/result.go @@ -321,7 +321,9 @@ type Result struct { Resolved *ResolvedInfo `json:"resolved,omitempty"` Greenfield bool `json:"greenfield"` Plan *Plan `json:"plan,omitempty"` - VenvPath string `json:"venvPath,omitempty"` + // VenvPath is the provisioned venv folder in slash form, relative to the + // project folder, or absolute when UV_PROJECT_ENVIRONMENT is absolute. + VenvPath string `json:"venvPath,omitempty"` // UvWorkspaceRoot is the uv workspace root (absolute, slash form) when the // project is a workspace member. The [tool.uv] constraint block and the venv // live there. Empty for a standalone project. diff --git a/libs/localenv/uv.go b/libs/localenv/uv.go index 40ef1deff3e..49d2eedc3ed 100644 --- a/libs/localenv/uv.go +++ b/libs/localenv/uv.go @@ -23,6 +23,12 @@ import ( // that would otherwise be declined for lack of a TTY. Any truthy value enables it. const EnvAutoInstallUv = "DATABRICKS_LOCALENV_AUTO_INSTALL_UV" +// EnvUvProjectEnvironment is uv's own setting for where `uv sync` puts the +// project environment: an absolute path as is, a relative one against the +// workspace root (or the project root outside a workspace). +// https://docs.astral.sh/uv/reference/environment/#uv_project_environment +const EnvUvProjectEnvironment = "UV_PROJECT_ENVIRONMENT" + // uvManager implements PackageManager using the uv tool. // https://docs.astral.sh/uv/ type uvManager struct {