From 446e1871f70cb3b5aedbb45c7774a0d0969a5d06 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 15:52:08 +0200 Subject: [PATCH 1/8] localenv: honor UV_PROJECT_ENVIRONMENT in setup-local uv sync puts the project environment at UV_PROJECT_ENVIRONMENT when set: an absolute path as is, a relative one against the workspace root or the project root. The pip seed, the validate probe, and venvPath now use that folder. Co-authored-by: Isaac --- acceptance/localenv/help/output.txt | 2 + cmd/environments/sync.go | 4 +- libs/localenv/pipeline.go | 33 ++++++++++-- libs/localenv/projectenv_test.go | 57 +++++++++++++++++++++ libs/localenv/provision_integration_test.go | 30 +++++++++++ libs/localenv/uv.go | 6 +++ 6 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 libs/localenv/projectenv_test.go 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/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/pipeline.go b/libs/localenv/pipeline.go index aa64acacb07..26cecb9f050 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -11,6 +11,7 @@ import ( "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" @@ -217,10 +218,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 +227,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 +327,32 @@ 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, relative to venvBase (the +// workspace root or ProjectDir). rootRel is venvBase relative to ProjectDir +// ("" outside a workspace), counted between symlink-resolved folders like every +// relative path the OS resolves. +func (p *Pipeline) setVenv(ctx context.Context, venvBase, rootRel string) error { + name := env.Get(ctx, EnvUvProjectEnvironment) + if name == "" { + name = venvDir + } + if !filepath.IsAbs(name) { + p.venv = filepath.Join(venvBase, name) + p.venvRel = filepath.Join(rootRel, name) + return nil + } + p.venv = name + resolved, err := filepath.EvalSymlinks(p.ProjectDir) + if err == nil { + p.venvRel, err = filepath.Rel(resolved, name) + } + if err != nil { + return p.fail(PhasePreflight, false, NewError(ErrUsage, err, "%s=%s cannot be reached by a path relative to the project folder; use a path on the same volume", EnvUvProjectEnvironment, filepath.ToSlash(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) diff --git a/libs/localenv/projectenv_test.go b/libs/localenv/projectenv_test.go new file mode 100644 index 00000000000..77f4e2c7fb6 --- /dev/null +++ b/libs/localenv/projectenv_test.go @@ -0,0 +1,57 @@ +package localenv + +import ( + "path/filepath" + "testing" + + "github.com/databricks/cli/libs/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPipelineProjectEnvironmentRelativeStandalone(t *testing.T) { + // uv resolves a relative UV_PROJECT_ENVIRONMENT against the project root. + dir := writeProject(t) + pm := &venvRecordingPM{fakePM: fakePM{py: "3.12", dbc: "17.2.0"}} + p := newServerlessTestPipeline(t, dir) + p.PM = pm + ctx := env.Set(t.Context(), EnvUvProjectEnvironment, "custom-env") + + res, err := p.Run(ctx) + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "custom-env"), pm.validateVenv) + assert.Equal(t, filepath.Join(dir, "custom-env"), pm.postProvisionVenv) + assert.Equal(t, "custom-env", res.VenvPath) +} + +func TestPipelineProjectEnvironmentRelativeMember(t *testing.T) { + // For a workspace member, uv resolves it against the workspace root. + p, root, _ := newMemberPipeline(t) + pm := &venvRecordingPM{fakePM: fakePM{py: "3.12", dbc: "17.2.0", root: root}} + p.PM = pm + ctx := env.Set(t.Context(), EnvUvProjectEnvironment, "custom-env") + + res, err := p.Run(ctx) + require.NoError(t, err) + assert.Equal(t, filepath.Join(root, "custom-env"), pm.validateVenv) + assert.Equal(t, "../../custom-env", res.VenvPath) +} + +func TestPipelineProjectEnvironmentAbsolute(t *testing.T) { + dir := writeProject(t) + abs := filepath.Join(t.TempDir(), "envs", "proj") + pm := &venvRecordingPM{fakePM: fakePM{py: "3.12", dbc: "17.2.0"}} + p := newServerlessTestPipeline(t, dir) + p.PM = pm + ctx := env.Set(t.Context(), EnvUvProjectEnvironment, abs) + + res, err := p.Run(ctx) + require.NoError(t, err) + assert.Equal(t, abs, pm.validateVenv) + // venvPath stays relative to the project folder, as the OS resolves it. + resolvedDir, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + want, err := filepath.Rel(resolvedDir, abs) + require.NoError(t, err) + assert.Equal(t, filepath.ToSlash(want), res.VenvPath) +} diff --git a/libs/localenv/provision_integration_test.go b/libs/localenv/provision_integration_test.go index c3e648bffc0..22b4264fad7 100644 --- a/libs/localenv/provision_integration_test.go +++ b/libs/localenv/provision_integration_test.go @@ -154,6 +154,36 @@ 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) + 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) + } + root := t.TempDir() + member := filepath.Join(root, "bundles", "a") + require.NoError(t, os.MkdirAll(member, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(member, pyprojectFile), + []byte("[project]\nname = \"a\"\nversion = \"0.1.0\"\nrequires-python = \">=3.10\"\n"), 0o644)) + 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)) + 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/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 { From d6e297b6e2fba4e9085bcbe95545cc7e8eb8c85d Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 15:52:56 +0200 Subject: [PATCH 2/8] Add a changelog fragment for UV_PROJECT_ENVIRONMENT support Co-authored-by: Isaac --- .nextchanges/cli/setup-local-uv-project-environment.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nextchanges/cli/setup-local-uv-project-environment.md 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)) From 0bc8887e963ad0f3e23ea11744515a751bebff9a Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 16:09:39 +0200 Subject: [PATCH 3/8] localenv: place UV_PROJECT_ENVIRONMENT exactly as uv does - Join a relative value to the symlink-resolved root, so .. climbs the real folders as in uv. - Report an absolute value as is; a relative path cannot cross drives. A Windows rooted path keeps the drive of the root, as in uv. - Quote an activate hint path that holds a space. - Clear the variable from the host shell in unit and acceptance tests; one table test; share the integration test setup. Co-authored-by: Isaac --- acceptance/acceptance_test.go | 4 + cmd/environments/output.go | 13 ++- cmd/environments/output_test.go | 10 +++ libs/localenv/main_test.go | 14 ++++ libs/localenv/pipeline.go | 40 +++++---- libs/localenv/pipeline_test.go | 89 +++++++++++++++++++++ libs/localenv/projectenv_test.go | 57 ------------- libs/localenv/provision_integration_test.go | 41 ++++++---- 8 files changed, 176 insertions(+), 92 deletions(-) create mode 100644 libs/localenv/main_test.go delete mode 100644 libs/localenv/projectenv_test.go 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/cmd/environments/output.go b/cmd/environments/output.go index f58a72b56ea..8d7cc614d56 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -155,9 +155,18 @@ 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 quoteIfSpaced(filepath.FromSlash(venvPath) + `\Scripts\activate`) } - return "source " + venvPath + "/bin/activate" + return "source " + quoteIfSpaced(venvPath+"/bin/activate") +} + +// quoteIfSpaced wraps path in double quotes when it holds a space, so the +// shell reads it as one argument (UV_PROJECT_ENVIRONMENT can name such a folder). +func quoteIfSpaced(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..aa9c8d20347 100644 --- a/cmd/environments/output_test.go +++ b/cmd/environments/output_test.go @@ -152,6 +152,16 @@ func TestActivateHintWorkspaceMember(t *testing.T) { } } +func TestActivateHintQuotesSpaces(t *testing.T) { + // UV_PROJECT_ENVIRONMENT can name a folder with spaces. + hint := activateHint("my env") + if runtime.GOOS == "windows" { + assert.Equal(t, `"my env\Scripts\activate"`, hint) + } else { + assert.Equal(t, `source "my env/bin/activate"`, hint) + } +} + func TestActivateHint(t *testing.T) { hint := activateHint(".venv") if runtime.GOOS == "windows" { 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 26cecb9f050..203159b4daf 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -78,8 +78,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 @@ -328,28 +329,35 @@ func (p *Pipeline) run(ctx context.Context) error { } // setVenv sets the venv folder where uv sync puts the project environment: -// UV_PROJECT_ENVIRONMENT when set, else .venv, relative to venvBase (the -// workspace root or ProjectDir). rootRel is venvBase relative to ProjectDir -// ("" outside a workspace), counted between symlink-resolved folders like every -// relative path the OS resolves. +// 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) if name == "" { - name = venvDir - } - if !filepath.IsAbs(name) { - p.venv = filepath.Join(venvBase, name) - p.venvRel = filepath.Join(rootRel, name) + p.venv = filepath.Join(venvBase, venvDir) + p.venvRel = filepath.Join(rootRel, venvDir) return nil } - p.venv = name - resolved, err := filepath.EvalSymlinks(p.ProjectDir) - if err == nil { - p.venvRel, err = filepath.Rel(resolved, name) + // uv uses an absolute value as is. On Windows a rooted path such as \envs\x + // is not absolute, and uv keeps only the drive of the root for it. An + // absolute venv is reported as is: a relative path cannot cross drives. + if filepath.IsAbs(name) || strings.HasPrefix(filepath.ToSlash(name), "/") { + if !filepath.IsAbs(name) { + name = filepath.VolumeName(venvBase) + name + } + p.venv = name + p.venvRel = name + return nil } + // uv joins a relative value to the symlink-resolved root, so ".." in it + // climbs the real folders. + resolvedBase, err := filepath.EvalSymlinks(venvBase) if err != nil { - return p.fail(PhasePreflight, false, NewError(ErrUsage, err, "%s=%s cannot be reached by a path relative to the project folder; use a path on the same volume", EnvUvProjectEnvironment, filepath.ToSlash(name))) + return p.fail(PhasePreflight, false, NewError(ErrUsage, err, "cannot resolve %s to place %s=%s", filepath.ToSlash(venvBase), EnvUvProjectEnvironment, filepath.ToSlash(name))) } + p.venv = filepath.Join(resolvedBase, name) + p.venvRel = filepath.Join(rootRel, name) return nil } diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index e1c2e735e28..7d9d0ac054f 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,91 @@ 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 +} + +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(base string) string + wantVenvPath 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(base string) string { return filepath.Join(resolvedPath(t, base), "custom-env") }, + wantVenvPath: "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(base string) string { return filepath.Join(resolvedPath(t, base), "custom-env") }, + wantVenvPath: "../../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(base string) string { return filepath.Join(filepath.Dir(resolvedPath(t, base)), "env") }, + wantVenvPath: "../env", + }, + { + 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(string) string { return abs }, + wantVenvPath: 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(base) + assert.Equal(t, want, pm.postProvisionVenv) + assert.Equal(t, want, pm.validateVenv) + assert.Equal(t, tc.wantVenvPath, res.VenvPath) + }) + } +} diff --git a/libs/localenv/projectenv_test.go b/libs/localenv/projectenv_test.go deleted file mode 100644 index 77f4e2c7fb6..00000000000 --- a/libs/localenv/projectenv_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package localenv - -import ( - "path/filepath" - "testing" - - "github.com/databricks/cli/libs/env" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestPipelineProjectEnvironmentRelativeStandalone(t *testing.T) { - // uv resolves a relative UV_PROJECT_ENVIRONMENT against the project root. - dir := writeProject(t) - pm := &venvRecordingPM{fakePM: fakePM{py: "3.12", dbc: "17.2.0"}} - p := newServerlessTestPipeline(t, dir) - p.PM = pm - ctx := env.Set(t.Context(), EnvUvProjectEnvironment, "custom-env") - - res, err := p.Run(ctx) - require.NoError(t, err) - assert.Equal(t, filepath.Join(dir, "custom-env"), pm.validateVenv) - assert.Equal(t, filepath.Join(dir, "custom-env"), pm.postProvisionVenv) - assert.Equal(t, "custom-env", res.VenvPath) -} - -func TestPipelineProjectEnvironmentRelativeMember(t *testing.T) { - // For a workspace member, uv resolves it against the workspace root. - p, root, _ := newMemberPipeline(t) - pm := &venvRecordingPM{fakePM: fakePM{py: "3.12", dbc: "17.2.0", root: root}} - p.PM = pm - ctx := env.Set(t.Context(), EnvUvProjectEnvironment, "custom-env") - - res, err := p.Run(ctx) - require.NoError(t, err) - assert.Equal(t, filepath.Join(root, "custom-env"), pm.validateVenv) - assert.Equal(t, "../../custom-env", res.VenvPath) -} - -func TestPipelineProjectEnvironmentAbsolute(t *testing.T) { - dir := writeProject(t) - abs := filepath.Join(t.TempDir(), "envs", "proj") - pm := &venvRecordingPM{fakePM: fakePM{py: "3.12", dbc: "17.2.0"}} - p := newServerlessTestPipeline(t, dir) - p.PM = pm - ctx := env.Set(t.Context(), EnvUvProjectEnvironment, abs) - - res, err := p.Run(ctx) - require.NoError(t, err) - assert.Equal(t, abs, pm.validateVenv) - // venvPath stays relative to the project folder, as the OS resolves it. - resolvedDir, err := filepath.EvalSymlinks(dir) - require.NoError(t, err) - want, err := filepath.Rel(resolvedDir, abs) - require.NoError(t, err) - assert.Equal(t, filepath.ToSlash(want), res.VenvPath) -} diff --git a/libs/localenv/provision_integration_test.go b/libs/localenv/provision_integration_test.go index 22b4264fad7..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) @@ -159,16 +173,9 @@ func TestProvisionWorkspaceMember(t *testing.T) { // against the workspace root. func TestProvisionWorkspaceMemberProjectEnvironment(t *testing.T) { requireRealProvision(t) - 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) - } - root := t.TempDir() + requireUvWorkspaceDir(t) + root := writeUvWorkspace(t, "a") member := filepath.Join(root, "bundles", "a") - require.NoError(t, os.MkdirAll(member, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(member, pyprojectFile), - []byte("[project]\nname = \"a\"\nversion = \"0.1.0\"\nrequires-python = \">=3.10\"\n"), 0o644)) - 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)) srv := serverlessConstraintServer(t) p := &Pipeline{ From aeff5351df1eb7ba99edade0d14d899245db2b83 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 16:23:21 +0200 Subject: [PATCH 4/8] localenv: keep UV_PROJECT_ENVIRONMENT uncleaned and quote the activate hint - Do not clean the value lexically: uv follows a symlink in it before it applies "..". Drop only a trailing separator. - Quote the Unix activate hint with shellquote.BashArg, so shell characters such as $ stay literal. - Report a failed folder resolution as E_NOT_WRITABLE, not a usage error. - Add an acceptance test for a member with UV_PROJECT_ENVIRONMENT and a Windows-only rooted-path case. Co-authored-by: Isaac --- .../bundles/a/pyproject.toml | 4 ++ .../workspace-member-project-env/fake-uv.sh | 26 ++++++++++++ .../out.test.toml | 2 + .../workspace-member-project-env/output.txt | 16 +++++++ .../pyproject.toml | 7 ++++ .../workspace-member-project-env/script | 14 +++++++ .../workspace-member-project-env/test.toml | 22 ++++++++++ cmd/environments/output.go | 7 ++-- cmd/environments/output_test.go | 12 +++--- libs/localenv/pipeline.go | 20 +++++---- libs/localenv/pipeline_test.go | 42 ++++++++++++++++++- 11 files changed, 155 insertions(+), 17 deletions(-) create mode 100644 acceptance/localenv/workspace-member-project-env/bundles/a/pyproject.toml create mode 100644 acceptance/localenv/workspace-member-project-env/fake-uv.sh create mode 100644 acceptance/localenv/workspace-member-project-env/out.test.toml create mode 100644 acceptance/localenv/workspace-member-project-env/output.txt create mode 100644 acceptance/localenv/workspace-member-project-env/pyproject.toml create mode 100644 acceptance/localenv/workspace-member-project-env/script create mode 100644 acceptance/localenv/workspace-member-project-env/test.toml 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..8eacf4d0f01 --- /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) + 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..271f3c24996 --- /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 (checked with cat below), their +# backups, and the shared .venv 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 8d7cc614d56..61f08181a1b 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" ) @@ -157,11 +158,11 @@ func activateHint(venvPath string) string { // relative path such as ../../.venv needs backslashes. return quoteIfSpaced(filepath.FromSlash(venvPath) + `\Scripts\activate`) } - return "source " + quoteIfSpaced(venvPath+"/bin/activate") + return "source " + shellquote.BashArg(venvPath+"/bin/activate") } -// quoteIfSpaced wraps path in double quotes when it holds a space, so the -// shell reads it as one argument (UV_PROJECT_ENVIRONMENT can name such a folder). +// quoteIfSpaced wraps path in double quotes when it holds a space, so cmd.exe +// reads it as one argument (UV_PROJECT_ENVIRONMENT can name such a folder). func quoteIfSpaced(path string) string { if strings.ContainsAny(path, " \t") { return `"` + path + `"` diff --git a/cmd/environments/output_test.go b/cmd/environments/output_test.go index aa9c8d20347..42b4e84f2c8 100644 --- a/cmd/environments/output_test.go +++ b/cmd/environments/output_test.go @@ -152,14 +152,14 @@ func TestActivateHintWorkspaceMember(t *testing.T) { } } -func TestActivateHintQuotesSpaces(t *testing.T) { - // UV_PROJECT_ENVIRONMENT can name a folder with spaces. - hint := activateHint("my env") +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"`, hint) - } else { - assert.Equal(t, `source "my env/bin/activate"`, hint) + assert.Equal(t, `"my env\Scripts\activate"`, activateHint("my env")) + 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")) } func TestActivateHint(t *testing.T) { diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 203159b4daf..07810630706 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -339,6 +339,9 @@ func (p *Pipeline) setVenv(ctx context.Context, venvBase, rootRel string) error p.venvRel = filepath.Join(rootRel, venvDir) return nil } + // The value is used as uv uses it: not cleaned, because uv follows a symlink + // in it before it applies "..". Only a trailing separator is dropped. + name = strings.TrimRight(name, `/\`) // uv uses an absolute value as is. On Windows a rooted path such as \envs\x // is not absolute, and uv keeps only the drive of the root for it. An // absolute venv is reported as is: a relative path cannot cross drives. @@ -354,10 +357,13 @@ func (p *Pipeline) setVenv(ctx context.Context, venvBase, rootRel string) error // climbs the real folders. resolvedBase, err := filepath.EvalSymlinks(venvBase) if err != nil { - return p.fail(PhasePreflight, false, NewError(ErrUsage, err, "cannot resolve %s to place %s=%s", filepath.ToSlash(venvBase), EnvUvProjectEnvironment, filepath.ToSlash(name))) + return p.fail(PhasePreflight, false, NewError(ErrNotWritable, err, "cannot resolve %s to place %s=%s", filepath.ToSlash(venvBase), EnvUvProjectEnvironment, filepath.ToSlash(name))) + } + p.venv = resolvedBase + string(filepath.Separator) + name + p.venvRel = name + if rootRel != "" { + p.venvRel = rootRel + string(filepath.Separator) + name } - p.venv = filepath.Join(resolvedBase, name) - p.venvRel = filepath.Join(rootRel, name) return nil } @@ -786,10 +792,10 @@ 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 + // 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). It is absolute only when UV_PROJECT_ENVIRONMENT is absolute. + // For a standalone project this is ".venv"; for a workspace member it is computed between the symlink-resolved // folders (see discoverWorkspaceRoot), for example "../../.venv". p.res.VenvPath = filepath.ToSlash(p.venvRel) if p.res.Resolved != nil { diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index 7d9d0ac054f..5c638a00076 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -1586,9 +1586,49 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { return link, real, fakePM{py: "3.12", dbc: "17.2.0"} }, value: filepath.Join("..", "env"), - wantVenv: func(base string) string { return filepath.Join(filepath.Dir(resolvedPath(t, base)), "env") }, + wantVenv: func(base string) string { return resolvedPath(t, base) + "/../env" }, wantVenvPath: "../env", }, + { + // uv follows link before it applies "..", so the path must not be + // cleaned lexically. + name: "relative through a symlink in the value", + setup: func(t *testing.T) (string, string, fakePM) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need extra privileges on Windows") + } + dir := writeProject(t) + require.NoError(t, os.Symlink(filepath.Join(t.TempDir(), "elsewhere"), filepath.Join(dir, "link"))) + return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} + }, + value: "link/../custom-env", + wantVenv: func(base string) string { return resolvedPath(t, base) + "/link/../custom-env" }, + wantVenvPath: "link/../custom-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(base string) string { return filepath.Join(resolvedPath(t, base), "custom-env") }, + wantVenvPath: "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(base string) string { return filepath.VolumeName(base) + `\envs\x` }, + wantVenvPath: filepath.ToSlash(`\envs\x`), + }, { name: "absolute", setup: func(t *testing.T) (string, string, fakePM) { From adb855e902a16dd9ba5792e739a52cfe98ec4fa3 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 16:42:16 +0200 Subject: [PATCH 5/8] localenv: simplify UV_PROJECT_ENVIRONMENT placement after review - Clean the value with filepath like the rest of the venv paths; keep the join to the symlink-resolved root. This drops the trailing-separator trim, which also removed a valid backslash on Unix. - Fix the Windows rooted-path test expectation (it includes the drive). - Quote the cmd.exe hint for its special characters; prefix ./ for a path that starts with a dash. - Document that venvPath can be absolute; fix stale comments. Co-authored-by: Isaac --- .../workspace-member-project-env/test.toml | 4 +- cmd/environments/output.go | 15 ++++-- cmd/environments/output_test.go | 3 ++ libs/localenv/pipeline.go | 54 +++++++++---------- libs/localenv/pipeline_test.go | 31 ++++------- libs/localenv/result.go | 4 +- 6 files changed, 52 insertions(+), 59 deletions(-) diff --git a/acceptance/localenv/workspace-member-project-env/test.toml b/acceptance/localenv/workspace-member-project-env/test.toml index 271f3c24996..c896c0fc247 100644 --- a/acceptance/localenv/workspace-member-project-env/test.toml +++ b/acceptance/localenv/workspace-member-project-env/test.toml @@ -2,8 +2,8 @@ # (mirrors acceptance/cmd/psql). GOOS.windows = false -# The run writes both pyproject.toml files (checked with cat below), their -# backups, and the shared .venv at the workspace root. +# 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" diff --git a/cmd/environments/output.go b/cmd/environments/output.go index 61f08181a1b..10f73e42160 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -156,15 +156,20 @@ 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 quoteIfSpaced(filepath.FromSlash(venvPath) + `\Scripts\activate`) + return quoteForCmd(filepath.FromSlash(venvPath) + `\Scripts\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") } -// quoteIfSpaced wraps path in double quotes when it holds a space, so cmd.exe -// reads it as one argument (UV_PROJECT_ENVIRONMENT can name such a folder). -func quoteIfSpaced(path string) string { - if strings.ContainsAny(path, " \t") { +// quoteForCmd wraps path in double quotes when cmd.exe would otherwise split +// it or read a special character in it (UV_PROJECT_ENVIRONMENT can name such a +// folder). +func quoteForCmd(path string) string { + if strings.ContainsAny(path, " \t&^()<>|,;=!") { return `"` + path + `"` } return path diff --git a/cmd/environments/output_test.go b/cmd/environments/output_test.go index 42b4e84f2c8..2349402dbb7 100644 --- a/cmd/environments/output_test.go +++ b/cmd/environments/output_test.go @@ -156,10 +156,13 @@ 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) { diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 07810630706..92a1fdd8713 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -334,35 +334,29 @@ func (p *Pipeline) run(ctx context.Context) error { // ProjectDir ("" outside a workspace; see discoverWorkspaceRoot). func (p *Pipeline) setVenv(ctx context.Context, venvBase, rootRel string) error { name := env.Get(ctx, EnvUvProjectEnvironment) - if name == "" { + switch { + case name == "": p.venv = filepath.Join(venvBase, venvDir) p.venvRel = filepath.Join(rootRel, venvDir) - return nil - } - // The value is used as uv uses it: not cleaned, because uv follows a symlink - // in it before it applies "..". Only a trailing separator is dropped. - name = strings.TrimRight(name, `/\`) - // uv uses an absolute value as is. On Windows a rooted path such as \envs\x - // is not absolute, and uv keeps only the drive of the root for it. An - // absolute venv is reported as is: a relative path cannot cross drives. - if filepath.IsAbs(name) || strings.HasPrefix(filepath.ToSlash(name), "/") { - if !filepath.IsAbs(name) { - name = filepath.VolumeName(venvBase) + name + 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 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 the symlink-resolved root, so ".." in it + // climbs the real folders. + resolvedBase, 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))) } - p.venv = name - p.venvRel = name - return nil - } - // uv joins a relative value to the symlink-resolved root, so ".." in it - // climbs the real folders. - resolvedBase, 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))) - } - p.venv = resolvedBase + string(filepath.Separator) + name - p.venvRel = name - if rootRel != "" { - p.venvRel = rootRel + string(filepath.Separator) + name + p.venv = filepath.Join(resolvedBase, name) + p.venvRel = filepath.Join(rootRel, name) } return nil } @@ -793,10 +787,10 @@ 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): the VS Code - // consumer already knows the project root (it sets the working directory when - // it shells out). It is absolute only when UV_PROJECT_ENVIRONMENT is absolute. - // For a standalone project this is ".venv"; for a workspace member it is computed between the symlink-resolved - // folders (see discoverWorkspaceRoot), for example "../../.venv". + // 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 5c638a00076..6996367ad5e 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -1586,25 +1586,9 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { return link, real, fakePM{py: "3.12", dbc: "17.2.0"} }, value: filepath.Join("..", "env"), - wantVenv: func(base string) string { return resolvedPath(t, base) + "/../env" }, + wantVenv: func(base string) string { return filepath.Join(filepath.Dir(resolvedPath(t, base)), "env") }, wantVenvPath: "../env", }, - { - // uv follows link before it applies "..", so the path must not be - // cleaned lexically. - name: "relative through a symlink in the value", - setup: func(t *testing.T) (string, string, fakePM) { - if runtime.GOOS == "windows" { - t.Skip("symlinks need extra privileges on Windows") - } - dir := writeProject(t) - require.NoError(t, os.Symlink(filepath.Join(t.TempDir(), "elsewhere"), filepath.Join(dir, "link"))) - return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} - }, - value: "link/../custom-env", - wantVenv: func(base string) string { return resolvedPath(t, base) + "/link/../custom-env" }, - wantVenvPath: "link/../custom-env", - }, { name: "relative with a trailing slash", setup: func(t *testing.T) (string, string, fakePM) { @@ -1625,9 +1609,10 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { dir := writeProject(t) return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} }, - value: `\envs\x`, - wantVenv: func(base string) string { return filepath.VolumeName(base) + `\envs\x` }, - wantVenvPath: filepath.ToSlash(`\envs\x`), + value: `\envs\x`, + wantVenv: func(base string) string { return filepath.VolumeName(base) + `\envs\x` }, + // The drive comes from the project, so the case derives it below. + wantVenvPath: "", }, { name: "absolute", @@ -1652,7 +1637,11 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { want := tc.wantVenv(base) assert.Equal(t, want, pm.postProvisionVenv) assert.Equal(t, want, pm.validateVenv) - assert.Equal(t, tc.wantVenvPath, res.VenvPath) + wantVenvPath := tc.wantVenvPath + if wantVenvPath == "" { + wantVenvPath = filepath.ToSlash(want) + } + assert.Equal(t, wantVenvPath, res.VenvPath) }) } } 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. From 9c9db4904ca7c6631a5d0199fdc882d4a547fcea Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 16:57:32 +0200 Subject: [PATCH 6/8] localenv: join UV_PROJECT_ENVIRONMENT like uv on Windows On Windows uv joins a relative value to the working folder as given, without resolving symlinks, so the CLI does too. Narrow the cmd.exe quoting comment and replace a sentinel in the table test with a function. Co-authored-by: Isaac --- cmd/environments/output.go | 4 ++-- libs/localenv/pipeline.go | 18 ++++++++++------ libs/localenv/pipeline_test.go | 38 ++++++++++++++++++++-------------- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/cmd/environments/output.go b/cmd/environments/output.go index 10f73e42160..51851497eb7 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -166,8 +166,8 @@ func activateHint(venvPath string) string { } // quoteForCmd wraps path in double quotes when cmd.exe would otherwise split -// it or read a special character in it (UV_PROJECT_ENVIRONMENT can name such a -// folder). +// 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 + `"` diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 92a1fdd8713..94bcb0a770b 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "time" @@ -349,13 +350,18 @@ func (p *Pipeline) setVenv(ctx context.Context, venvBase, rootRel string) error p.venv = filepath.Clean(filepath.VolumeName(venvBase) + name) p.venvRel = p.venv default: - // uv joins a relative value to the symlink-resolved root, so ".." in it - // climbs the real folders. - resolvedBase, 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))) + // 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(resolvedBase, name) + p.venv = filepath.Join(base, name) p.venvRel = filepath.Join(rootRel, name) } return nil diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index 6996367ad5e..58b116091c2 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -1538,6 +1538,16 @@ func resolvedPath(t *testing.T, path string) string { 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, @@ -1550,7 +1560,7 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { setup func(t *testing.T) (projectDir, base string, pm fakePM) value string wantVenv func(base string) string - wantVenvPath string + wantVenvPath func(venv string) string }{ { name: "relative, standalone", @@ -1559,8 +1569,8 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} }, value: "custom-env", - wantVenv: func(base string) string { return filepath.Join(resolvedPath(t, base), "custom-env") }, - wantVenvPath: "custom-env", + wantVenv: func(base string) string { return filepath.Join(uvJoinBase(t, base), "custom-env") }, + wantVenvPath: func(string) string { return "custom-env" }, }, { name: "relative, workspace member", @@ -1571,8 +1581,8 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { return member, root, fakePM{py: "3.12", dbc: "17.2.0", root: root} }, value: "custom-env", - wantVenv: func(base string) string { return filepath.Join(resolvedPath(t, base), "custom-env") }, - wantVenvPath: "../../custom-env", + wantVenv: func(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", @@ -1587,7 +1597,7 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { }, value: filepath.Join("..", "env"), wantVenv: func(base string) string { return filepath.Join(filepath.Dir(resolvedPath(t, base)), "env") }, - wantVenvPath: "../env", + wantVenvPath: func(string) string { return "../env" }, }, { name: "relative with a trailing slash", @@ -1596,8 +1606,8 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} }, value: "custom-env/", - wantVenv: func(base string) string { return filepath.Join(resolvedPath(t, base), "custom-env") }, - wantVenvPath: "custom-env", + wantVenv: func(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. @@ -1611,8 +1621,8 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { }, value: `\envs\x`, wantVenv: func(base string) string { return filepath.VolumeName(base) + `\envs\x` }, - // The drive comes from the project, so the case derives it below. - wantVenvPath: "", + // The drive comes from the project. + wantVenvPath: filepath.ToSlash, }, { name: "absolute", @@ -1622,7 +1632,7 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { }, value: abs, wantVenv: func(string) string { return abs }, - wantVenvPath: filepath.ToSlash(abs), + wantVenvPath: func(string) string { return filepath.ToSlash(abs) }, }, } for _, tc := range tests { @@ -1637,11 +1647,7 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { want := tc.wantVenv(base) assert.Equal(t, want, pm.postProvisionVenv) assert.Equal(t, want, pm.validateVenv) - wantVenvPath := tc.wantVenvPath - if wantVenvPath == "" { - wantVenvPath = filepath.ToSlash(want) - } - assert.Equal(t, wantVenvPath, res.VenvPath) + assert.Equal(t, tc.wantVenvPath(want), res.VenvPath) }) } } From 6153b59f02c7666c575bad373fd06f4dcafab876 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 17:41:30 +0200 Subject: [PATCH 7/8] Update the UV_PROJECT_ENVIRONMENT golden for the uv workspace root label Co-authored-by: Isaac --- acceptance/localenv/workspace-member-project-env/output.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/localenv/workspace-member-project-env/output.txt b/acceptance/localenv/workspace-member-project-env/output.txt index 8eacf4d0f01..a5366a75bdf 100644 --- a/acceptance/localenv/workspace-member-project-env/output.txt +++ b/acceptance/localenv/workspace-member-project-env/output.txt @@ -8,7 +8,7 @@ warning: requires-python ">=3.10" is replaced by the environment's ">=3.12" databricks-connect 17.2.0 Virtual env ../../custom env pyproject.toml updated (backup: [TEST_TMP_DIR]/bundles/a/pyproject.toml.bak) - Workspace root [TEST_TMP_DIR] + uv workspace root [TEST_TMP_DIR] Root pyproject.toml constraints updated (backup: [TEST_TMP_DIR]/pyproject.toml.bak) Next steps: From 5131bd3a0fdb3c3ee53deb423bbcab182bb01556 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 18:18:07 +0200 Subject: [PATCH 8/8] localenv: use a Windows drive-relative UV_PROJECT_ENVIRONMENT as is uv uses a value such as C:envs\x as is, so the CLI does too, not joined to the root. The table test now passes each subtest its own t. Co-authored-by: Isaac --- libs/localenv/pipeline.go | 5 +++++ libs/localenv/pipeline_test.go | 35 +++++++++++++++++++++++++--------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 94bcb0a770b..99506128210 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -344,6 +344,11 @@ func (p *Pipeline) setVenv(ctx context.Context, venvBase, rootRel string) error // 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. diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index 58b116091c2..d08a3696069 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -1559,7 +1559,7 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { // joined to, and the fake package manager. setup func(t *testing.T) (projectDir, base string, pm fakePM) value string - wantVenv func(base string) string + wantVenv func(t *testing.T, base string) string wantVenvPath func(venv string) string }{ { @@ -1569,7 +1569,7 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} }, value: "custom-env", - wantVenv: func(base string) string { return filepath.Join(uvJoinBase(t, base), "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" }, }, { @@ -1581,7 +1581,7 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { return member, root, fakePM{py: "3.12", dbc: "17.2.0", root: root} }, value: "custom-env", - wantVenv: func(base string) string { return filepath.Join(uvJoinBase(t, base), "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" }, }, { @@ -1595,8 +1595,10 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { require.NoError(t, os.Symlink(real, link)) return link, real, fakePM{py: "3.12", dbc: "17.2.0"} }, - value: filepath.Join("..", "env"), - wantVenv: func(base string) string { return filepath.Join(filepath.Dir(resolvedPath(t, base)), "env") }, + 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" }, }, { @@ -1606,7 +1608,7 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} }, value: "custom-env/", - wantVenv: func(base string) string { return filepath.Join(uvJoinBase(t, base), "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" }, }, { @@ -1620,10 +1622,25 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} }, value: `\envs\x`, - wantVenv: func(base string) string { return filepath.VolumeName(base) + `\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) { @@ -1631,7 +1648,7 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { return dir, dir, fakePM{py: "3.12", dbc: "17.2.0"} }, value: abs, - wantVenv: func(string) string { return abs }, + wantVenv: func(*testing.T, string) string { return abs }, wantVenvPath: func(string) string { return filepath.ToSlash(abs) }, }, } @@ -1644,7 +1661,7 @@ func TestPipelineUsesUvProjectEnvironment(t *testing.T) { res, err := p.Run(env.Set(t.Context(), EnvUvProjectEnvironment, tc.value)) require.NoError(t, err) - want := tc.wantVenv(base) + 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)