From 3603c273faf8ba8c8c98211ba9dc4cd10c0f6d5a Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 13:11:20 +0200 Subject: [PATCH 01/17] localenv: discover the uv workspace root with uv workspace dir Co-authored-by: Isaac --- libs/localenv/pipeline_test.go | 13 +++++- libs/localenv/pkgmanager.go | 5 +++ libs/localenv/uv.go | 28 +++++++++++++ libs/localenv/workspace.go | 51 +++++++++++++++++++++++ libs/localenv/workspace_test.go | 73 +++++++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 libs/localenv/workspace.go create mode 100644 libs/localenv/workspace_test.go diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index fda5f63e0ea..d09c8372b96 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -27,13 +27,20 @@ const cancelPMStderr = "error: no solution found: databricks-connect==17.2 confl // below — a plain ">= 0" assertion would also hold for the unset field. const fetchDelay = 25 * time.Millisecond -type fakePM struct{ py, dbc, pyspark, dbcImportErr string } +type fakePM struct{ py, dbc, pyspark, dbcImportErr, root string } func (fakePM) Name() string { return "fake" } func (fakePM) EnsureAvailable(context.Context) (string, error) { return "fake 1.0", nil } func (fakePM) EnsurePython(_ context.Context, minor string) (PythonSelection, error) { return PythonSelection{Executable: minor, Resolution: PythonResolutionUVInstallSucceeded}, nil } +func (f fakePM) WorkspaceRoot(_ context.Context, projectDir string) (string, error) { + if f.root != "" { + return f.root, nil + } + return projectDir, nil +} + func (fakePM) Provision(context.Context, string, string) error { return nil } func (fakePM) PostProvision(context.Context, string, string) error { return nil } func (f fakePM) Validate(context.Context, string, string) (VenvInfo, error) { @@ -54,6 +61,10 @@ func (noProvisionPM) EnsurePython(context.Context, string) (PythonSelection, err return PythonSelection{}, errors.New("EnsurePython must not be called under --dry-run") } +func (noProvisionPM) WorkspaceRoot(_ context.Context, projectDir string) (string, error) { + return projectDir, nil +} + func (noProvisionPM) Provision(context.Context, string, string) error { return errors.New("Provision must not be called under --dry-run") } diff --git a/libs/localenv/pkgmanager.go b/libs/localenv/pkgmanager.go index dba3b084fa3..18c3767a2e4 100644 --- a/libs/localenv/pkgmanager.go +++ b/libs/localenv/pkgmanager.go @@ -16,6 +16,11 @@ type PackageManager interface { // minor. The returned executable is passed unchanged to Provision. EnsurePython(ctx context.Context, minor string) (PythonSelection, error) + // WorkspaceRoot returns the absolute root of the workspace that the package + // manager uses for projectDir. For a project that is not a workspace member, + // this is projectDir itself. + WorkspaceRoot(ctx context.Context, projectDir string) (string, error) + // Provision installs the project dependencies inside projectDir, pinning the // environment to python, which is either a minor request (e.g. "3.12") or an // exact interpreter path selected by EnsurePython. diff --git a/libs/localenv/uv.go b/libs/localenv/uv.go index c62b84429ac..0318ee992c3 100644 --- a/libs/localenv/uv.go +++ b/libs/localenv/uv.go @@ -300,6 +300,34 @@ func lineWithPrefix(out, prefix string) (string, bool) { return "", false } +// WorkspaceRoot asks uv for the workspace root of projectDir (`uv workspace +// dir`, added in uv 0.9.9). Under --dry-run EnsureAvailable has not run, so the +// binary is discovered here without installing it. +// https://docs.astral.sh/uv/reference/cli/#uv-workspace-dir +func (m *uvManager) WorkspaceRoot(ctx context.Context, projectDir string) (string, error) { + bin := m.bin + if bin == "" { + found, err := discoverUv(ctx) + if err != nil { + return "", err + } + bin = found + } + out, err := process.Background(ctx, append([]string{bin}, m.workspaceDirArgs()...), + process.WithDir(projectDir), + process.WithProcessGroup(), + ) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// workspaceDirArgs returns the argument slice for `uv workspace dir`. +func (m *uvManager) workspaceDirArgs() []string { + return []string{"workspace", "dir"} +} + // syncArgs returns the argument slice for `uv sync` (without the binary), // pinning the interpreter to python via --python. func (m *uvManager) syncArgs(python string) []string { diff --git a/libs/localenv/workspace.go b/libs/localenv/workspace.go new file mode 100644 index 00000000000..740702b415f --- /dev/null +++ b/libs/localenv/workspace.go @@ -0,0 +1,51 @@ +package localenv + +import ( + "context" + "path/filepath" + + "github.com/databricks/cli/libs/log" +) + +// discoverWorkspaceRoot returns the uv workspace root when ProjectDir is a +// workspace member, or "" when it is not (a standalone project, the root itself, +// or a project uv cannot place). A folder with no pyproject.toml yet (greenfield) +// is not a member: uv would walk up to a parent project, but the file this +// command creates is not listed in any workspace. +// +// A discovery failure (uv older than 0.9.9, uv not found under --dry-run, a +// malformed workspace) keeps today's behavior. A malformed workspace still fails +// later in uv sync, with uv's own message. +func (p *Pipeline) discoverWorkspaceRoot(ctx context.Context) string { + if !fileExists(p.pyprojectPath()) { + return "" + } + root, err := p.PM.WorkspaceRoot(ctx, p.ProjectDir) + if err != nil { + log.Debugf(ctx, CommandName+": workspace discovery failed, using the project folder: %v", err) + return "" + } + same, err := samePath(root, p.ProjectDir) + if err != nil { + log.Debugf(ctx, CommandName+": cannot compare workspace root %s with the project folder: %v", filepath.ToSlash(root), err) + return "" + } + if same { + return "" + } + return root +} + +// samePath reports whether a and b name the same folder after resolving +// symlinks (macOS temp folders live under /var, a symlink to /private/var). +func samePath(a, b string) (bool, error) { + ra, err := filepath.EvalSymlinks(a) + if err != nil { + return false, err + } + rb, err := filepath.EvalSymlinks(b) + if err != nil { + return false, err + } + return ra == rb, nil +} diff --git a/libs/localenv/workspace_test.go b/libs/localenv/workspace_test.go new file mode 100644 index 00000000000..2bc3a4bd739 --- /dev/null +++ b/libs/localenv/workspace_test.go @@ -0,0 +1,73 @@ +package localenv + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// rootPM reports a fixed workspace root (or error) from WorkspaceRoot. +type rootPM struct { + fakePM + root string + err error +} + +func (f rootPM) WorkspaceRoot(context.Context, string) (string, error) { return f.root, f.err } + +func memberLayout(t *testing.T) (root, member string) { + root = t.TempDir() + member = filepath.Join(root, "bundles", "a") + require.NoError(t, os.MkdirAll(member, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, pyprojectFile), []byte("[tool.uv.workspace]\nmembers = [\"bundles/*\"]\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(member, pyprojectFile), []byte("[project]\nname = \"a\"\n"), 0o644)) + return root, member +} + +func TestDiscoverWorkspaceRootMember(t *testing.T) { + root, member := memberLayout(t) + p := &Pipeline{ProjectDir: member, PM: rootPM{root: root}} + assert.Equal(t, root, p.discoverWorkspaceRoot(t.Context())) +} + +func TestDiscoverWorkspaceRootStandalone(t *testing.T) { + _, member := memberLayout(t) + p := &Pipeline{ProjectDir: member, PM: rootPM{root: member}} + assert.Empty(t, p.discoverWorkspaceRoot(t.Context())) +} + +func TestDiscoverWorkspaceRootSymlinkedSameDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need extra privileges on Windows") + } + _, member := memberLayout(t) + link := filepath.Join(t.TempDir(), "link") + require.NoError(t, os.Symlink(member, link)) + // uv prints the resolved path; ProjectDir is the symlinked one. + p := &Pipeline{ProjectDir: link, PM: rootPM{root: member}} + assert.Empty(t, p.discoverWorkspaceRoot(t.Context())) +} + +func TestDiscoverWorkspaceRootSkipsGreenfield(t *testing.T) { + root := t.TempDir() + member := filepath.Join(root, "new") + require.NoError(t, os.MkdirAll(member, 0o755)) + p := &Pipeline{ProjectDir: member, PM: rootPM{root: root}} + assert.Empty(t, p.discoverWorkspaceRoot(t.Context())) +} + +func TestDiscoverWorkspaceRootUvFailure(t *testing.T) { + _, member := memberLayout(t) + p := &Pipeline{ProjectDir: member, PM: rootPM{err: errors.New("unrecognized subcommand 'workspace'")}} + assert.Empty(t, p.discoverWorkspaceRoot(t.Context())) +} + +func TestWorkspaceDirArgs(t *testing.T) { + assert.Equal(t, []string{"workspace", "dir"}, (&uvManager{}).workspaceDirArgs()) +} From 269d4a8fdf49b2e38559f060a4fec289ba64c7a2 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 13:14:26 +0200 Subject: [PATCH 02/17] localenv: provision the shared venv and write pins at the uv workspace root Co-authored-by: Isaac --- libs/localenv/merge.go | 134 ++++++++++++++++++++------------ libs/localenv/merge_test.go | 32 ++++++++ libs/localenv/pipeline.go | 116 +++++++++++++++++++++++---- libs/localenv/pipeline_test.go | 6 +- libs/localenv/result.go | 44 +++++++---- libs/localenv/workspace_test.go | 123 +++++++++++++++++++++++++++++ 6 files changed, 373 insertions(+), 82 deletions(-) diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go index 388feed0bbd..f945e2424de 100644 --- a/libs/localenv/merge.go +++ b/libs/localenv/merge.go @@ -113,6 +113,11 @@ type MergeOptions struct { // databricks-connect dependency unmanaged: an existing pin is preserved and none // is injected or asserted. SkipDBConnect bool + // SkipToolUv leaves the [tool.uv] constraint block of this file unmanaged. It + // is set for a uv workspace member, whose constraint block is written to the + // workspace root instead (uv reads constraint-dependencies only from the root). + // requires-python is still managed in the member. + SkipToolUv bool } // MergeManaged applies the managed transforms to target, preserving every other @@ -121,6 +126,79 @@ type MergeOptions struct { // back in produces identical bytes. opts selects which axes are managed; the // environment region is always reconciled. func MergeManaged(target []byte, c Constraints, opts MergeOptions) (merged []byte, regions []string, err error) { + merged, err = transformLines(target, func(lines []string) ([]string, error) { + // requires-python needs a [project] table to hold it; without one the merge + // would silently drop the pin, so fail loudly instead. Only enforced when + // constraints are managed — under SkipConstraints there is no requires-python to + // write, so a [project]-less file (e.g. one with only dependency groups) is + // merged for its other axes rather than rejected. + if !opts.SkipConstraints { + if _, _, ok := tableBounds(lines, "[project]"); !ok { + return nil, errNoProjectTable + } + } + + if !opts.SkipConstraints { + var rpChanged bool + lines, rpChanged = mergeRequiresPython(lines, c.RequiresPython) + if rpChanged { + regions = append(regions, regionRequiresPython) + } + } + + if !opts.SkipDBConnect { + var dbcChanged bool + lines, _, dbcChanged = mergeDatabricksConnect(lines, c.DatabricksConnect) + // In the install flow, after the managed pin lands in the dev group, remove any + // databricks-connect pin elsewhere that is disjoint from it — the pins that would + // otherwise make uv unsatisfiable. Compatible pins are left alone. An empty pin + // (an artifact without databricks-connect) is a data no-op, distinct from the + // SkipDBConnect intent handled by the gate above. + strayChanged := false + if c.DatabricksConnect != "" { + lines, _, strayChanged = removeStrayDatabricksConnect(lines, c.DatabricksConnect) + } + if dbcChanged || strayChanged { + regions = append(regions, regionDatabricksConnect) + } + } + + lines, envChanged := mergeDatabricksEnvironment(lines, c.EnvironmentVersion) + if envChanged { + regions = append(regions, regionDatabricksEnvironment) + } + + if !opts.SkipConstraints && !opts.SkipToolUv { + var uvChanged bool + lines, uvChanged = mergeToolUv(lines, c.ConstraintDeps) + if uvChanged { + regions = append(regions, regionToolUv) + } + } + return lines, nil + }) + if err != nil { + return nil, nil, err + } + return merged, regions, nil +} + +// MergeToolUvBlock reconciles only the managed [tool.uv] constraint-dependencies +// block of target, preserving every other byte. It is used for the root +// pyproject.toml of a uv workspace, which may be a virtual root with no [project] +// table. changed reports whether the block differs from the input. +func MergeToolUvBlock(target []byte, deps []string) (merged []byte, changed bool, err error) { + merged, err = transformLines(target, func(lines []string) ([]string, error) { + var out []string + out, changed = mergeToolUv(lines, deps) + return out, nil + }) + return merged, changed, err +} + +// transformLines runs fn over target split into lines, with CRLF normalized to +// LF and multiline strings protected, and restores both on the way out. +func transformLines(target []byte, fn func([]string) ([]string, error)) ([]byte, error) { s := string(target) // Detect and normalize line endings. We process on "\n" and restore "\r\n" on @@ -136,64 +214,18 @@ func MergeManaged(target []byte, c Constraints, opts MergeOptions) (merged []byt protected, restore, err := protectMultilineStrings(s) if err != nil { - return nil, nil, err - } - lines := strings.Split(protected, "\n") - - // requires-python needs a [project] table to hold it; without one the merge - // would silently drop the pin, so fail loudly instead. Only enforced when - // constraints are managed — under SkipConstraints there is no requires-python to - // write, so a [project]-less file (e.g. one with only dependency groups) is - // merged for its other axes rather than rejected. - if !opts.SkipConstraints { - if _, _, ok := tableBounds(lines, "[project]"); !ok { - return nil, nil, errNoProjectTable - } - } - - if !opts.SkipConstraints { - var rpChanged bool - lines, rpChanged = mergeRequiresPython(lines, c.RequiresPython) - if rpChanged { - regions = append(regions, regionRequiresPython) - } + return nil, err } - - if !opts.SkipDBConnect { - var dbcChanged bool - lines, _, dbcChanged = mergeDatabricksConnect(lines, c.DatabricksConnect) - // In the install flow, after the managed pin lands in the dev group, remove any - // databricks-connect pin elsewhere that is disjoint from it — the pins that would - // otherwise make uv unsatisfiable. Compatible pins are left alone. An empty pin - // (an artifact without databricks-connect) is a data no-op, distinct from the - // SkipDBConnect intent handled by the gate above. - strayChanged := false - if c.DatabricksConnect != "" { - lines, _, strayChanged = removeStrayDatabricksConnect(lines, c.DatabricksConnect) - } - if dbcChanged || strayChanged { - regions = append(regions, regionDatabricksConnect) - } - } - - lines, envChanged := mergeDatabricksEnvironment(lines, c.EnvironmentVersion) - if envChanged { - regions = append(regions, regionDatabricksEnvironment) - } - - if !opts.SkipConstraints { - var uvChanged bool - lines, uvChanged = mergeToolUv(lines, c.ConstraintDeps) - if uvChanged { - regions = append(regions, regionToolUv) - } + lines, err := fn(strings.Split(protected, "\n")) + if err != nil { + return nil, err } out := restore(strings.Join(lines, "\n")) if crlf { out = strings.ReplaceAll(out, "\n", "\r\n") } - return []byte(out), regions, nil + return []byte(out), nil } // headerName returns the bracketed table name of a TOML table-header line (e.g. diff --git a/libs/localenv/merge_test.go b/libs/localenv/merge_test.go index 3e175ae0e2a..2cc5b20fcc2 100644 --- a/libs/localenv/merge_test.go +++ b/libs/localenv/merge_test.go @@ -1271,3 +1271,35 @@ func TestRenderFreshPyprojectSkipDBConnectOmitsDBConnect(t *testing.T) { assert.Contains(t, s, "constraint-dependencies") requireValidTOML(t, out) } + +func TestMergeManagedSkipToolUvLeavesBlockAlone(t *testing.T) { + in := []byte("[project]\nname = \"a\"\nrequires-python = \">=3.10\"\n") + c := Constraints{RequiresPython: ">=3.12", ConstraintDeps: []string{"numpy==1.26.4"}} + out, regions, err := MergeManaged(in, c, MergeOptions{SkipToolUv: true}) + require.NoError(t, err) + assert.Contains(t, string(out), `requires-python = ">=3.12"`) + assert.NotContains(t, string(out), "constraint-dependencies") + assert.NotContains(t, regions, regionToolUv) +} + +func TestMergeToolUvBlockVirtualRoot(t *testing.T) { + in := []byte("[tool.uv.workspace]\nmembers = [\"bundles/*\"]\n") + out, changed, err := MergeToolUvBlock(in, []string{"numpy==1.26.4"}) + require.NoError(t, err) + assert.True(t, changed) + assert.Contains(t, string(out), managedMarkerStart) + assert.Contains(t, string(out), `"numpy==1.26.4"`) + assert.Contains(t, string(out), "[tool.uv.workspace]") + + again, changed2, err := MergeToolUvBlock(out, []string{"numpy==1.26.4"}) + require.NoError(t, err) + assert.False(t, changed2) + assert.Equal(t, out, again) +} + +func TestMergeToolUvBlockKeepsCRLF(t *testing.T) { + in := []byte("[tool.uv.workspace]\r\nmembers = [\"a\"]\r\n") + out, _, err := MergeToolUvBlock(in, []string{"x==1"}) + require.NoError(t, err) + assert.NotContains(t, strings.ReplaceAll(string(out), "\r\n", ""), "\n") +} diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 81c55837e2d..fef28b19b1d 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -81,6 +81,10 @@ type Pipeline struct { // Result.VenvPath. Set in preflight. venvRel string + // workspaceRoot is the uv workspace root when ProjectDir is a workspace + // member, else "". Set in preflight. + workspaceRoot string + // nowFn supplies the time for backup filenames; nil means time.Now. Injected // in tests so backup names are deterministic. nowFn func() time.Time @@ -181,8 +185,6 @@ func (p *Pipeline) run(ctx context.Context) error { if m := detectManager(p.ProjectDir); m != managerUv { return p.fail(PhasePreflight, false, NewError(ErrManagerUnsupported, nil, "%s", managerGuidance(m))) } - p.venvRel = venvDir - p.venv = filepath.Join(p.ProjectDir, p.venvRel) // Under --dry-run the pipeline only reads and reports a plan, so it must not // mutate anything at preflight. Two preflight steps can write: // - ensureWritable creates and removes a temp file (and would fail a @@ -190,9 +192,8 @@ func (p *Pipeline) run(ctx context.Context) error { // - PackageManager.EnsureAvailable may install the manager (uv) if missing. // Both exist to fail fast before real writes, which --dry-run never performs, so // they are skipped in a dry run. Neither result is needed to compute the plan. - if p.Check { - p.markOK(PhasePreflight, "check") - } else { + detail := "check" + if !p.Check { if err := ensureWritable(p.ProjectDir); err != nil { return p.fail(PhasePreflight, false, NewError(ErrNotWritable, err, "project directory %s is not writable", filepath.ToSlash(p.ProjectDir))) } @@ -200,8 +201,26 @@ func (p *Pipeline) run(ctx context.Context) error { if err != nil { return p.fail(PhasePreflight, false, asPipelineError(err, ErrUvMissing, "%s unavailable", p.PM.Name())) } - p.markOK(PhasePreflight, p.PM.Name()+" "+version) + detail = p.PM.Name() + " " + version + } + // A uv workspace member shares the venv at the workspace root, where uv sync + // creates it, and keeps its constraint block in the root pyproject.toml, the + // only file uv reads constraint-dependencies from. + // https://docs.astral.sh/uv/concepts/projects/workspaces/ + p.workspaceRoot = p.discoverWorkspaceRoot(ctx) + venvBase := p.ProjectDir + if p.workspaceRoot != "" { + venvBase = p.workspaceRoot + p.res.WorkspaceRoot = filepath.ToSlash(p.workspaceRoot) + if !p.Check { + if err := ensureWritable(p.workspaceRoot); err != nil { + return p.fail(PhasePreflight, false, NewError(ErrNotWritable, err, "workspace root %s is not writable", filepath.ToSlash(p.workspaceRoot))) + } + } } + p.venv = filepath.Join(venvBase, venvDir) + p.venvRel = venvDir + p.markOK(PhasePreflight, detail) // Phase: resolve — compute target → environment key. p.report(ctx, PhaseResolve) @@ -247,7 +266,7 @@ func (p *Pipeline) run(ctx context.Context) error { // The serverless environment version (empty for cluster targets) is written // into [tool.databricks.environment] so the project also runs in serverless Jobs. p.report(ctx, PhaseMerge) - mergedBytes, greenfield, err := p.mergePlan(ctx, pyMinor, c, compute.ServerlessEnvironmentVersion(), opts) + mergedBytes, root, greenfield, err := p.mergePlan(ctx, pyMinor, c, compute.ServerlessEnvironmentVersion(), opts) if err != nil { return err } @@ -262,7 +281,7 @@ func (p *Pipeline) run(ctx context.Context) error { } // Apply the merged content to disk (backup first for existing projects). - if err := p.applyMerge(ctx, mergedBytes, greenfield); err != nil { + if err := p.applyMerge(ctx, mergedBytes, root, greenfield); err != nil { return err } p.markOK(PhaseMerge, "") @@ -385,7 +404,7 @@ func (p *Pipeline) plannedBackupName(dir string) (string, error) { // --dry-run). opts selects which managed axes are written. envVersion is the // serverless environment version to write into [tool.databricks.environment], or // "" for a cluster target. -func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, envVersion string, opts MergeOptions) (merged []byte, greenfield bool, err error) { +func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, envVersion string, opts MergeOptions) (merged []byte, root *rootMerge, greenfield bool, err error) { pyproject := p.pyprojectPath() // The merge base is the live pyproject.toml, not the backup. MergeManaged @@ -406,7 +425,7 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, // after detectManager saw it) must not be misread as greenfield — that // would render a fresh file and overwrite the user's project with no // backup. Fail instead of destroying unrecoverable state (invariant 2). - return nil, false, p.fail(PhaseMerge, false, NewError(ErrMerge, rerr, "read pyproject.toml %s failed", filepath.ToSlash(pyproject))) + return nil, nil, false, p.fail(PhaseMerge, false, NewError(ErrMerge, rerr, "read pyproject.toml %s failed", filepath.ToSlash(pyproject))) } greenfield = baseBytes == nil @@ -421,6 +440,12 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, effective := *c effective.EnvironmentVersion = envVersion + // A workspace member's constraint block goes to the workspace root (see + // planRootMerge), so the member merge leaves its own [tool.uv] alone. A + // greenfield project is never a member (discoverWorkspaceRoot), so + // RenderFreshPyproject needs no such option. + opts.SkipToolUv = p.workspaceRoot != "" + var changedRegions []string if greenfield { // No existing pyproject.toml — render a fresh one. The project name comes @@ -440,7 +465,7 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, } else { merged, changedRegions, err = MergeManaged(baseBytes, effective, opts) if err != nil { - return nil, greenfield, p.fail(PhaseMerge, false, NewError(ErrMerge, err, "merge managed regions failed")) + return nil, nil, greenfield, p.fail(PhaseMerge, false, NewError(ErrMerge, err, "merge managed regions failed")) } // Surface merge-quality warnings (overridden, consolidated, or duplicated pins, // conflicting user constraints) from the pre-merge file. Greenfield has nothing of @@ -452,12 +477,24 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, detectMergeWarnings(baseBytes, effective, planDBConnect(baseBytes, effective, opts), opts)...) } + root, err = p.planRootMerge(c.ConstraintDeps, opts) + if err != nil { + return nil, nil, greenfield, err + } + if root != nil && !bytes.Equal(root.base, root.merged) { + changedRegions = append(changedRegions, regionToolUv) + } + // Under --dry-run, build the plan (with a diff) for reporting. A real run does // not need the diff. if p.Check { + diff := pyprojectDiff(pyprojectFile, baseBytes, merged, greenfield) + if root != nil { + diff += pyprojectDiff("workspace root "+pyprojectFile, root.base, root.merged, false) + } plan := &Plan{ WouldWrite: filepath.ToSlash(pyproject), - Diff: pyprojectDiff(pyprojectFile, baseBytes, merged, greenfield), + Diff: diff, ChangedRegions: changedRegions, WouldInstallPython: pyMinor, } @@ -468,9 +505,49 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, plan.WouldBackup = filepath.ToSlash(backupName) } } + if root != nil { + plan.WouldWriteWorkspace = filepath.ToSlash(root.path) + if !bytes.Equal(root.base, root.merged) { + if backupName, statErr := p.plannedBackupName(p.workspaceRoot); statErr == nil { + plan.WouldBackupWorkspace = filepath.ToSlash(backupName) + } + } + } p.res.Plan = plan } - return merged, greenfield, nil + return merged, root, greenfield, nil +} + +// rootMerge is the planned change to the workspace root pyproject.toml. +type rootMerge struct { + path string + base []byte + merged []byte +} + +// planRootMerge merges the constraint block into the workspace root +// pyproject.toml. It returns nil when there is no workspace root or when +// constraints are not managed (--no-constraints). +func (p *Pipeline) planRootMerge(deps []string, opts MergeOptions) (*rootMerge, error) { + if p.workspaceRoot == "" || opts.SkipConstraints { + return nil, nil + } + path := filepath.Join(p.workspaceRoot, pyprojectFile) + base, err := os.ReadFile(path) + if err != nil { + return nil, p.fail(PhaseMerge, false, NewError(ErrMerge, err, "read workspace root pyproject.toml %s failed", filepath.ToSlash(path))) + } + merged, changed, err := MergeToolUvBlock(base, deps) + if err != nil { + return nil, p.fail(PhaseMerge, false, NewError(ErrMerge, err, "merge workspace root constraints failed")) + } + if changed && bytes.Contains(base, []byte(managedMarkerStart)) { + p.res.Warnings = append(p.res.Warnings, Warning{ + Code: WarnWorkspaceConstraintsReplaced, + Message: fmt.Sprintf("the constraint pins in %s are replaced; all members of this uv workspace share them", filepath.ToSlash(path)), + }) + } + return &rootMerge{path: path, base: base, merged: merged}, nil } // pyprojectDiff renders the unified diff the --dry-run plan reports for one @@ -492,7 +569,7 @@ func pyprojectDiff(label string, base, merged []byte, greenfield bool) string { // applyMerge writes the merged bytes to disk, backing up the current // pyproject.toml first. From the backup copy onward, disk has been mutated. -func (p *Pipeline) applyMerge(_ context.Context, mergedBytes []byte, greenfield bool) error { +func (p *Pipeline) applyMerge(_ context.Context, mergedBytes []byte, root *rootMerge, greenfield bool) error { backup, mutated, perr := p.writePyproject(p.pyprojectPath(), mergedBytes, greenfield) // A backup written before a failed write is still reported, so the consumer // can restore from it. @@ -502,6 +579,17 @@ func (p *Pipeline) applyMerge(_ context.Context, mergedBytes []byte, greenfield if perr != nil { return p.fail(PhaseMerge, mutated, perr) } + if root != nil { + // The member is written first; if the root write fails, disk is already + // mutated, so the failure always reports diskMutated. + backup, _, perr := p.writePyproject(root.path, root.merged, false) + if backup != "" { + p.res.WorkspaceBackupPath = filepath.ToSlash(backup) + } + if perr != nil { + return p.fail(PhaseMerge, true, perr) + } + } return nil } diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index d09c8372b96..29f722d7750 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -851,7 +851,7 @@ func TestApplyMergeFailsOnUnreadableDirWithoutOverwritingBackup(t *testing.T) { t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) p := &Pipeline{ProjectDir: dir, res: &Result{Phases: initialPhases()}} - err := p.applyMerge(t.Context(), []byte("merged"), false) + err := p.applyMerge(t.Context(), []byte("merged"), nil, false) require.Error(t, err) var pe *PipelineError require.ErrorAs(t, err, &pe) @@ -1079,13 +1079,13 @@ func TestApplyMergeSameInstantBackupsGetUniqueNames(t *testing.T) { } // First modifying apply: backs up "current-1" under a timestamped name. - require.NoError(t, p.applyMerge(t.Context(), []byte("merged-1\n"), false)) + require.NoError(t, p.applyMerge(t.Context(), []byte("merged-1\n"), nil, false)) first := p.res.BackupPath require.NotEmpty(t, first) // Second modifying apply at the SAME instant: backs up "merged-1" but must not // collide with or overwrite the first timestamped backup. - require.NoError(t, p.applyMerge(t.Context(), []byte("merged-2\n"), false)) + require.NoError(t, p.applyMerge(t.Context(), []byte("merged-2\n"), nil, false)) second := p.res.BackupPath require.NotEmpty(t, second) diff --git a/libs/localenv/result.go b/libs/localenv/result.go index ea256661c9f..af25ee424e8 100644 --- a/libs/localenv/result.go +++ b/libs/localenv/result.go @@ -213,10 +213,14 @@ type ResolvedInfo struct { // Plan describes the changes a --dry-run run would apply (spec §6.3). // ChangedRegions is retained for text output only and is not serialized. type Plan struct { - WouldWrite string `json:"wouldWrite"` - WouldBackup string `json:"wouldBackup,omitempty"` - WouldInstallPython string `json:"wouldInstallPython,omitempty"` - Diff string `json:"diff"` + WouldWrite string `json:"wouldWrite"` + WouldBackup string `json:"wouldBackup,omitempty"` + // WouldWriteWorkspace and WouldBackupWorkspace name the workspace root + // pyproject.toml and its planned backup, for a uv workspace member only. + WouldWriteWorkspace string `json:"wouldWriteWorkspace,omitempty"` + WouldBackupWorkspace string `json:"wouldBackupWorkspace,omitempty"` + WouldInstallPython string `json:"wouldInstallPython,omitempty"` + Diff string `json:"diff"` ChangedRegions []string `json:"-"` } @@ -293,6 +297,11 @@ const ( // the user's own pyproject pins it (constraints-only mode) — so it agrees with the // validate hard-fail, which keys on the installed venv rather than the mode. WarnStandalonePysparkConflict = "W_STANDALONE_PYSPARK_CONFLICT" + // WarnWorkspaceConstraintsReplaced: the project is a uv workspace member and + // the workspace root already had a managed constraint block with different + // pins (for example from another bundle's run). The new pins replace them, and + // every member of the workspace now resolves against them. + WarnWorkspaceConstraintsReplaced = "W_WORKSPACE_CONSTRAINTS_REPLACED" ) // Result is the full outcome of a sync run and the root of the --json object @@ -303,16 +312,20 @@ const ( // a distinction that trips JSON consumers and golden diffs. Construct a Result // with NewResult (or otherwise seed both) rather than a bare Result{} literal. type Result struct { - SchemaVersion int `json:"schemaVersion"` - Command string `json:"command"` - OK bool `json:"ok"` - Mode string `json:"mode"` - DryRun bool `json:"dryRun"` - Compute *ComputeInfo `json:"compute,omitempty"` - Resolved *ResolvedInfo `json:"resolved,omitempty"` - Greenfield bool `json:"greenfield"` - Plan *Plan `json:"plan,omitempty"` - VenvPath string `json:"venvPath,omitempty"` + SchemaVersion int `json:"schemaVersion"` + Command string `json:"command"` + OK bool `json:"ok"` + Mode string `json:"mode"` + DryRun bool `json:"dryRun"` + Compute *ComputeInfo `json:"compute,omitempty"` + Resolved *ResolvedInfo `json:"resolved,omitempty"` + Greenfield bool `json:"greenfield"` + Plan *Plan `json:"plan,omitempty"` + VenvPath string `json:"venvPath,omitempty"` + // WorkspaceRoot 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. + WorkspaceRoot string `json:"workspaceRoot,omitempty"` PythonResolution PythonResolution `json:"pythonResolution,omitempty"` // PythonInterpreter is the exact interpreter chosen by the installed-Python // fallback, named in the text summary so the user can tell which interpreter @@ -328,6 +341,9 @@ type Result struct { Warnings []Warning `json:"warnings"` Error *PipelineError `json:"error"` BackupPath string `json:"backupPath,omitempty"` + // WorkspaceBackupPath is the backup of the workspace root pyproject.toml, set + // only when the run changed that file. + WorkspaceBackupPath string `json:"workspaceBackupPath,omitempty"` // DurationMs is the pipeline's wall time in milliseconds (spec §6). It covers the // CLI pipeline only; the extension measures its own end-to-end latency (process // spawn, interpreter adoption) separately. diff --git a/libs/localenv/workspace_test.go b/libs/localenv/workspace_test.go index 2bc3a4bd739..de5fc82aee2 100644 --- a/libs/localenv/workspace_test.go +++ b/libs/localenv/workspace_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -71,3 +72,125 @@ func TestDiscoverWorkspaceRootUvFailure(t *testing.T) { func TestWorkspaceDirArgs(t *testing.T) { assert.Equal(t, []string{"workspace", "dir"}, (&uvManager{}).workspaceDirArgs()) } + +func newMemberPipeline(t *testing.T) (p *Pipeline, root, member string) { + root, member = memberLayout(t) + // The member needs requires-python so the existing merge accepts it. + require.NoError(t, os.WriteFile(filepath.Join(member, pyprojectFile), + []byte("[project]\nname = \"a\"\nversion = \"0.1.0\"\nrequires-python = \">=3.10\"\n"), 0o644)) + p = newServerlessTestPipeline(t, member) + p.PM = fakePM{py: "3.12", dbc: "17.2.0", root: root} + return p, root, member +} + +func warningCodes(res *Result) []string { + var codes []string + for _, w := range res.Warnings { + codes = append(codes, w.Code) + } + return codes +} + +func TestPipelineWorkspaceMember(t *testing.T) { + p, root, member := newMemberPipeline(t) + res, err := p.Run(t.Context()) + require.NoError(t, err) + + rootBytes, err := os.ReadFile(filepath.Join(root, pyprojectFile)) + require.NoError(t, err) + memberBytes, err := os.ReadFile(filepath.Join(member, pyprojectFile)) + require.NoError(t, err) + assert.Contains(t, string(rootBytes), "constraint-dependencies") + assert.Contains(t, string(rootBytes), "[tool.uv.workspace]") + assert.NotContains(t, string(memberBytes), "constraint-dependencies") + assert.Contains(t, string(memberBytes), "requires-python") + assert.Contains(t, string(memberBytes), `"databricks-connect~=17.2.0"`) + + assert.Equal(t, "../../.venv", res.VenvPath) + assert.Equal(t, filepath.ToSlash(root), res.WorkspaceRoot) + assert.Equal(t, filepath.ToSlash(filepath.Join(root, backupFile)), res.WorkspaceBackupPath) + assert.Equal(t, filepath.ToSlash(filepath.Join(member, backupFile)), res.BackupPath) + assert.NotContains(t, warningCodes(res), WarnWorkspaceConstraintsReplaced, "first run must not warn") +} + +func TestPipelineWorkspaceMemberIdempotent(t *testing.T) { + p, root, member := newMemberPipeline(t) + _, err := p.Run(t.Context()) + require.NoError(t, err) + res, err := p.Run(t.Context()) + require.NoError(t, err) + assert.Empty(t, res.BackupPath) + assert.Empty(t, res.WorkspaceBackupPath) + assert.NotContains(t, warningCodes(res), WarnWorkspaceConstraintsReplaced) + for _, dir := range []string{root, member} { + matches, _ := filepath.Glob(filepath.Join(dir, "pyproject.toml.*.bak")) + assert.Empty(t, matches, "no timestamped backup in %s", dir) + } +} + +func TestPipelineWorkspaceConstraintsReplacedWarning(t *testing.T) { + p, root, _ := newMemberPipeline(t) + _, err := p.Run(t.Context()) + require.NoError(t, err) + // Simulate another bundle's run: change the pins inside the managed block. + path := filepath.Join(root, pyprojectFile) + b, err := os.ReadFile(path) + require.NoError(t, err) + changed := strings.Replace(string(b), "constraint-dependencies = [", "constraint-dependencies = [\n \"other==1.0\",", 1) + require.NoError(t, os.WriteFile(path, []byte(changed), 0o644)) + + res, err := p.Run(t.Context()) + require.NoError(t, err) + assert.Contains(t, warningCodes(res), WarnWorkspaceConstraintsReplaced) +} + +func TestPipelineWorkspaceMemberNoConstraints(t *testing.T) { + p, root, _ := newMemberPipeline(t) + p.SkipConstraints = true + before, err := os.ReadFile(filepath.Join(root, pyprojectFile)) + require.NoError(t, err) + res, err := p.Run(t.Context()) + require.NoError(t, err) + after, err := os.ReadFile(filepath.Join(root, pyprojectFile)) + require.NoError(t, err) + assert.Equal(t, before, after) + assert.Empty(t, res.WorkspaceBackupPath) + assert.NoFileExists(t, filepath.Join(root, backupFile)) +} + +func TestPipelineWorkspaceMemberDryRun(t *testing.T) { + p, root, member := newMemberPipeline(t) + p.Check = true + before, err := os.ReadFile(filepath.Join(root, pyprojectFile)) + require.NoError(t, err) + res, err := p.Run(t.Context()) + require.NoError(t, err) + after, err := os.ReadFile(filepath.Join(root, pyprojectFile)) + require.NoError(t, err) + assert.Equal(t, before, after) + require.NotNil(t, res.Plan) + assert.Equal(t, filepath.ToSlash(filepath.Join(member, pyprojectFile)), res.Plan.WouldWrite) + assert.Equal(t, filepath.ToSlash(filepath.Join(root, pyprojectFile)), res.Plan.WouldWriteWorkspace) + assert.Equal(t, filepath.ToSlash(filepath.Join(root, backupFile)), res.Plan.WouldBackupWorkspace) + assert.Contains(t, res.Plan.Diff, "workspace root") +} + +func TestPipelineWorkspaceRootNotWritable(t *testing.T) { + if runtime.GOOS == "windows" || os.Getuid() == 0 { + t.Skip("directory permissions do not block writes on Windows or as root") + } + p, root, member := newMemberPipeline(t) + require.NoError(t, os.Chmod(root, 0o555)) + t.Cleanup(func() { _ = os.Chmod(root, 0o755) }) + memberBefore, err := os.ReadFile(filepath.Join(member, pyprojectFile)) + require.NoError(t, err) + + res, err := p.Run(t.Context()) + require.Error(t, err) + require.NotNil(t, res.Error) + assert.Equal(t, ErrNotWritable, res.Error.Code) + assert.Contains(t, res.Error.Error(), filepath.ToSlash(root)) + memberAfter, err := os.ReadFile(filepath.Join(member, pyprojectFile)) + require.NoError(t, err) + assert.Equal(t, memberBefore, memberAfter) +} From 8174563edc04e00b1c886c66c90e9dec91fabb67 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 13:18:35 +0200 Subject: [PATCH 03/17] environments: report the uv workspace root in setup-local output Co-authored-by: Isaac --- .nextchanges/cli/setup-local-uv-workspace.md | 1 + acceptance/localenv/help/output.txt | 2 + .../fake-uv.sh | 8 ++ .../out.test.toml | 2 + .../output.txt | 60 +++++++++++++ .../pyproject.toml | 4 + .../script | 11 +++ .../test.toml | 18 ++++ .../bundles/a/pyproject.toml | 4 + .../workspace-member-check/fake-uv.sh | 8 ++ .../workspace-member-check/out.test.toml | 2 + .../workspace-member-check/output.txt | 73 +++++++++++++++ .../workspace-member-check/pyproject.toml | 7 ++ .../localenv/workspace-member-check/script | 16 ++++ .../localenv/workspace-member-check/test.toml | 18 ++++ .../workspace-member/bundles/a/pyproject.toml | 4 + .../localenv/workspace-member/fake-uv.sh | 24 +++++ .../localenv/workspace-member/out.test.toml | 2 + .../localenv/workspace-member/output.txt | 90 +++++++++++++++++++ .../localenv/workspace-member/pyproject.toml | 7 ++ acceptance/localenv/workspace-member/script | 19 ++++ .../localenv/workspace-member/test.toml | 22 +++++ cmd/environments/output.go | 11 +++ cmd/environments/output_test.go | 36 ++++++++ cmd/environments/sync.go | 4 +- libs/localenv/pipeline_test.go | 1 + libs/localenv/workspace.go | 27 +++--- libs/localenv/workspace_test.go | 16 ++++ 28 files changed, 485 insertions(+), 12 deletions(-) create mode 100644 .nextchanges/cli/setup-local-uv-workspace.md create mode 100644 acceptance/localenv/workspace-discovery-unsupported-check/fake-uv.sh create mode 100644 acceptance/localenv/workspace-discovery-unsupported-check/out.test.toml create mode 100644 acceptance/localenv/workspace-discovery-unsupported-check/output.txt create mode 100644 acceptance/localenv/workspace-discovery-unsupported-check/pyproject.toml create mode 100644 acceptance/localenv/workspace-discovery-unsupported-check/script create mode 100644 acceptance/localenv/workspace-discovery-unsupported-check/test.toml create mode 100644 acceptance/localenv/workspace-member-check/bundles/a/pyproject.toml create mode 100644 acceptance/localenv/workspace-member-check/fake-uv.sh create mode 100644 acceptance/localenv/workspace-member-check/out.test.toml create mode 100644 acceptance/localenv/workspace-member-check/output.txt create mode 100644 acceptance/localenv/workspace-member-check/pyproject.toml create mode 100644 acceptance/localenv/workspace-member-check/script create mode 100644 acceptance/localenv/workspace-member-check/test.toml create mode 100644 acceptance/localenv/workspace-member/bundles/a/pyproject.toml create mode 100644 acceptance/localenv/workspace-member/fake-uv.sh create mode 100644 acceptance/localenv/workspace-member/out.test.toml create mode 100644 acceptance/localenv/workspace-member/output.txt create mode 100644 acceptance/localenv/workspace-member/pyproject.toml create mode 100644 acceptance/localenv/workspace-member/script create mode 100644 acceptance/localenv/workspace-member/test.toml diff --git a/.nextchanges/cli/setup-local-uv-workspace.md b/.nextchanges/cli/setup-local-uv-workspace.md new file mode 100644 index 00000000000..48ef55e036c --- /dev/null +++ b/.nextchanges/cli/setup-local-uv-workspace.md @@ -0,0 +1 @@ +* `databricks environments setup-local` now supports uv workspace members: it uses the shared `.venv` at the workspace root and writes the dependency constraints to the root `pyproject.toml`. diff --git a/acceptance/localenv/help/output.txt b/acceptance/localenv/help/output.txt index af1e32e15df..96ce3beb021 100644 --- a/acceptance/localenv/help/output.txt +++ b/acceptance/localenv/help/output.txt @@ -2,6 +2,8 @@ Set up a local Python environment that matches a Databricks cluster or serverles 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. + Usage: databricks environments setup-local [flags] diff --git a/acceptance/localenv/workspace-discovery-unsupported-check/fake-uv.sh b/acceptance/localenv/workspace-discovery-unsupported-check/fake-uv.sh new file mode 100644 index 00000000000..9e18385562b --- /dev/null +++ b/acceptance/localenv/workspace-discovery-unsupported-check/fake-uv.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# uv older than 0.9.9 has no `workspace` subcommand. +if [ "$1" = "workspace" ]; then + echo "error: unrecognized subcommand 'workspace'" >&2 + exit 2 +fi +echo "fake uv: unexpected call: $*" >&2 +exit 1 diff --git a/acceptance/localenv/workspace-discovery-unsupported-check/out.test.toml b/acceptance/localenv/workspace-discovery-unsupported-check/out.test.toml new file mode 100644 index 00000000000..2e1543aa802 --- /dev/null +++ b/acceptance/localenv/workspace-discovery-unsupported-check/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +GOOS.windows = false diff --git a/acceptance/localenv/workspace-discovery-unsupported-check/output.txt b/acceptance/localenv/workspace-discovery-unsupported-check/output.txt new file mode 100644 index 00000000000..3d64de6720a --- /dev/null +++ b/acceptance/localenv/workspace-discovery-unsupported-check/output.txt @@ -0,0 +1,60 @@ + +>>> [CLI] environments setup-local --serverless-version 4 --dry-run --output json +{ + "schemaVersion": 1, + "command": "environments setup-local", + "ok": true, + "mode": "default", + "dryRun": true, + "compute": { + "source": "serverless", + "serverlessVersion": "v4", + "envKey": "serverless/serverless-v4" + }, + "resolved": { + "pythonVersion": "3.12", + "dbconnectVersion": "17.2.0", + "artifactSource": "network" + }, + "greenfield": false, + "plan": { + "wouldWrite": "[TEST_TMP_DIR]/pyproject.toml", + "wouldBackup": "[TEST_TMP_DIR]/pyproject.toml.bak", + "wouldInstallPython": "3.12", + "diff": "--- pyproject.toml\n+++ pyproject.toml.new\n@@ -1,4 +1,20 @@\n [project]\n name = \"a\"\n version = \"0.1.0\"\n-requires-python = \"\u003e=3.10\"\n+requires-python = \"\u003e=3.12\"\n+\n+[dependency-groups]\n+dev = [\n+ \"databricks-connect~=17.2.0\",\n+]\n+\n+[tool.databricks.environment]\n+environment_version = \"4\"\n+\n+# managed by databricks environments setup-local — do not edit\n+[tool.uv]\n+constraint-dependencies = [\n+ \"pyarrow\u003c19\",\n+ \"pandas\u003c3\",\n+]\n+# end managed by databricks environments setup-local\n" + }, + "phases": [ + { + "phase": "preflight", + "status": "ok" + }, + { + "phase": "resolve", + "status": "ok" + }, + { + "phase": "fetch", + "status": "ok" + }, + { + "phase": "merge", + "status": "ok" + }, + { + "phase": "provision", + "status": "ok" + }, + { + "phase": "validate", + "status": "ok" + } + ], + "warnings": [ + { + "code": "W_REQUIRES_PYTHON_OVERRIDDEN", + "message": "requires-python \"\u003e=3.10\" is replaced by the environment's \"\u003e=3.12\"" + } + ], + "error": null, + "durationMs": [DURATION_MS] +} diff --git a/acceptance/localenv/workspace-discovery-unsupported-check/pyproject.toml b/acceptance/localenv/workspace-discovery-unsupported-check/pyproject.toml new file mode 100644 index 00000000000..962b088a2b0 --- /dev/null +++ b/acceptance/localenv/workspace-discovery-unsupported-check/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "a" +version = "0.1.0" +requires-python = ">=3.10" diff --git a/acceptance/localenv/workspace-discovery-unsupported-check/script b/acceptance/localenv/workspace-discovery-unsupported-check/script new file mode 100644 index 00000000000..dd535bf55a3 --- /dev/null +++ b/acceptance/localenv/workspace-discovery-unsupported-check/script @@ -0,0 +1,11 @@ +# uv older than 0.9.9 cannot report the workspace root, so the plan is the same +# as for a standalone project: no workspace fields. +mv fake-uv.sh uv +chmod +x uv +cleanup() { + rm -f uv +} +trap cleanup EXIT +export PATH="$(pwd):$PATH" + +trace $CLI environments setup-local --serverless-version 4 --dry-run --output json diff --git a/acceptance/localenv/workspace-discovery-unsupported-check/test.toml b/acceptance/localenv/workspace-discovery-unsupported-check/test.toml new file mode 100644 index 00000000000..1f85ab26622 --- /dev/null +++ b/acceptance/localenv/workspace-discovery-unsupported-check/test.toml @@ -0,0 +1,18 @@ +# The fake uv is a bash script, so this test cannot run on Windows runners +# (mirrors acceptance/cmd/psql). +GOOS.windows = false + +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/acceptance/localenv/workspace-member-check/bundles/a/pyproject.toml b/acceptance/localenv/workspace-member-check/bundles/a/pyproject.toml new file mode 100644 index 00000000000..962b088a2b0 --- /dev/null +++ b/acceptance/localenv/workspace-member-check/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-check/fake-uv.sh b/acceptance/localenv/workspace-member-check/fake-uv.sh new file mode 100644 index 00000000000..34452908cdd --- /dev/null +++ b/acceptance/localenv/workspace-member-check/fake-uv.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# The workspace root is the test folder, where this script is installed as uv. +if [ "$1" = "workspace" ] && [ "$2" = "dir" ]; then + cd "$(dirname "$0")" && pwd + exit 0 +fi +echo "fake uv: unexpected call: $*" >&2 +exit 1 diff --git a/acceptance/localenv/workspace-member-check/out.test.toml b/acceptance/localenv/workspace-member-check/out.test.toml new file mode 100644 index 00000000000..2e1543aa802 --- /dev/null +++ b/acceptance/localenv/workspace-member-check/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +GOOS.windows = false diff --git a/acceptance/localenv/workspace-member-check/output.txt b/acceptance/localenv/workspace-member-check/output.txt new file mode 100644 index 00000000000..0dcf026ce60 --- /dev/null +++ b/acceptance/localenv/workspace-member-check/output.txt @@ -0,0 +1,73 @@ + +>>> [CLI] environments setup-local --serverless-version 4 --dry-run +warning: requires-python ">=3.10" is replaced by the environment's ">=3.12" +Plan: [TEST_TMP_DIR]/bundles/a/pyproject.toml +Plan: [TEST_TMP_DIR]/pyproject.toml (workspace root) + changed region: requires-python + changed region: databricks-connect + changed region: tool.databricks.environment + changed region: tool.uv.constraint-dependencies +Check complete. No files were modified. + +>>> [CLI] environments setup-local --serverless-version 4 --dry-run --output json +{ + "schemaVersion": 1, + "command": "environments setup-local", + "ok": true, + "mode": "default", + "dryRun": true, + "compute": { + "source": "serverless", + "serverlessVersion": "v4", + "envKey": "serverless/serverless-v4" + }, + "resolved": { + "pythonVersion": "3.12", + "dbconnectVersion": "17.2.0", + "artifactSource": "network" + }, + "greenfield": false, + "plan": { + "wouldWrite": "[TEST_TMP_DIR]/bundles/a/pyproject.toml", + "wouldBackup": "[TEST_TMP_DIR]/bundles/a/pyproject.toml.bak", + "wouldWriteWorkspace": "[TEST_TMP_DIR]/pyproject.toml", + "wouldBackupWorkspace": "[TEST_TMP_DIR]/pyproject.toml.bak", + "wouldInstallPython": "3.12", + "diff": "--- pyproject.toml\n+++ pyproject.toml.new\n@@ -1,4 +1,12 @@\n [project]\n name = \"a\"\n version = \"0.1.0\"\n-requires-python = \"\u003e=3.10\"\n+requires-python = \"\u003e=3.12\"\n+\n+[dependency-groups]\n+dev = [\n+ \"databricks-connect~=17.2.0\",\n+]\n+\n+[tool.databricks.environment]\n+environment_version = \"4\"\n--- workspace root pyproject.toml\n+++ workspace root pyproject.toml.new\n@@ -5,3 +5,11 @@\n \n [tool.uv.workspace]\n members = [\"bundles/*\"]\n+\n+# managed by databricks environments setup-local — do not edit\n+[tool.uv]\n+constraint-dependencies = [\n+ \"pyarrow\u003c19\",\n+ \"pandas\u003c3\",\n+]\n+# end managed by databricks environments setup-local\n" + }, + "workspaceRoot": "[TEST_TMP_DIR]", + "phases": [ + { + "phase": "preflight", + "status": "ok" + }, + { + "phase": "resolve", + "status": "ok" + }, + { + "phase": "fetch", + "status": "ok" + }, + { + "phase": "merge", + "status": "ok" + }, + { + "phase": "provision", + "status": "ok" + }, + { + "phase": "validate", + "status": "ok" + } + ], + "warnings": [ + { + "code": "W_REQUIRES_PYTHON_OVERRIDDEN", + "message": "requires-python \"\u003e=3.10\" is replaced by the environment's \"\u003e=3.12\"" + } + ], + "error": null, + "durationMs": [DURATION_MS] +} diff --git a/acceptance/localenv/workspace-member-check/pyproject.toml b/acceptance/localenv/workspace-member-check/pyproject.toml new file mode 100644 index 00000000000..7a29f61f24a --- /dev/null +++ b/acceptance/localenv/workspace-member-check/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-check/script b/acceptance/localenv/workspace-member-check/script new file mode 100644 index 00000000000..254939cf906 --- /dev/null +++ b/acceptance/localenv/workspace-member-check/script @@ -0,0 +1,16 @@ +# A uv workspace member: the plan writes the member pyproject.toml and the +# constraint block into the workspace root pyproject.toml. A fake uv on PATH +# answers `uv workspace dir` (see acceptance/cmd/psql for the pattern). +mv fake-uv.sh uv +chmod +x uv +# The script changes folder, so remove the fake uv by its absolute path. +fake_uv="$(pwd)/uv" +cleanup() { + rm -f "$fake_uv" +} +trap cleanup EXIT +export PATH="$(pwd):$PATH" + +cd bundles/a +trace $CLI environments setup-local --serverless-version 4 --dry-run +trace $CLI environments setup-local --serverless-version 4 --dry-run --output json diff --git a/acceptance/localenv/workspace-member-check/test.toml b/acceptance/localenv/workspace-member-check/test.toml new file mode 100644 index 00000000000..1f85ab26622 --- /dev/null +++ b/acceptance/localenv/workspace-member-check/test.toml @@ -0,0 +1,18 @@ +# The fake uv is a bash script, so this test cannot run on Windows runners +# (mirrors acceptance/cmd/psql). +GOOS.windows = false + +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/acceptance/localenv/workspace-member/bundles/a/pyproject.toml b/acceptance/localenv/workspace-member/bundles/a/pyproject.toml new file mode 100644 index 00000000000..962b088a2b0 --- /dev/null +++ b/acceptance/localenv/workspace-member/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/fake-uv.sh b/acceptance/localenv/workspace-member/fake-uv.sh new file mode 100644 index 00000000000..9b63430c86e --- /dev/null +++ b/acceptance/localenv/workspace-member/fake-uv.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# The workspace root is the test folder, where this script is installed as uv. +# `uv sync` creates the shared venv there with a fake interpreter that answers +# the validate probe (prefixes from libs/localenv/uv.go). +root="$(cd "$(dirname "$0")" && pwd)" +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 "$root/.venv/bin" + cat > "$root/.venv/bin/python" <<'PY' +#!/bin/bash +echo "PYVER:3.12" +echo "DBCVER:17.2.0" +echo "PYSPARKVER:" +echo "DBCIMPORT:" +PY + chmod +x "$root/.venv/bin/python" + exit 0 ;; +esac +echo "fake uv: unexpected call: $*" >&2 +exit 1 diff --git a/acceptance/localenv/workspace-member/out.test.toml b/acceptance/localenv/workspace-member/out.test.toml new file mode 100644 index 00000000000..2e1543aa802 --- /dev/null +++ b/acceptance/localenv/workspace-member/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +GOOS.windows = false diff --git a/acceptance/localenv/workspace-member/output.txt b/acceptance/localenv/workspace-member/output.txt new file mode 100644 index 00000000000..bc2a2ddc78f --- /dev/null +++ b/acceptance/localenv/workspace-member/output.txt @@ -0,0 +1,90 @@ + +>>> [CLI] environments setup-local --serverless-version 4 --output json +{ + "schemaVersion": 1, + "command": "environments setup-local", + "ok": true, + "mode": "default", + "dryRun": false, + "compute": { + "source": "serverless", + "serverlessVersion": "v4", + "envKey": "serverless/serverless-v4" + }, + "resolved": { + "pythonVersion": "3.12", + "dbconnectVersion": "17.2.0", + "artifactSource": "network" + }, + "greenfield": false, + "venvPath": "../../.venv", + "workspaceRoot": "[TEST_TMP_DIR]", + "pythonResolution": "uv_install_succeeded", + "phases": [ + { + "phase": "preflight", + "status": "ok" + }, + { + "phase": "resolve", + "status": "ok" + }, + { + "phase": "fetch", + "status": "ok" + }, + { + "phase": "merge", + "status": "ok" + }, + { + "phase": "provision", + "status": "ok" + }, + { + "phase": "validate", + "status": "ok" + } + ], + "warnings": [ + { + "code": "W_REQUIRES_PYTHON_OVERRIDDEN", + "message": "requires-python \"\u003e=3.10\" is replaced by the environment's \"\u003e=3.12\"" + } + ], + "error": null, + "backupPath": "[TEST_TMP_DIR]/bundles/a/pyproject.toml.bak", + "workspaceBackupPath": "[TEST_TMP_DIR]/pyproject.toml.bak", + "durationMs": [DURATION_MS] +} + +>>> cat pyproject.toml +[project] +name = "root" +version = "0.1.0" +requires-python = ">=3.10" + +[tool.uv.workspace] +members = ["bundles/*"] + +# managed by databricks environments setup-local — do not edit +[tool.uv] +constraint-dependencies = [ + "pyarrow<19", + "pandas<3", +] +# end managed by databricks environments setup-local + +>>> cat bundles/a/pyproject.toml +[project] +name = "a" +version = "0.1.0" +requires-python = ">=3.12" + +[dependency-groups] +dev = [ + "databricks-connect~=17.2.0", +] + +[tool.databricks.environment] +environment_version = "4" diff --git a/acceptance/localenv/workspace-member/pyproject.toml b/acceptance/localenv/workspace-member/pyproject.toml new file mode 100644 index 00000000000..7a29f61f24a --- /dev/null +++ b/acceptance/localenv/workspace-member/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/script b/acceptance/localenv/workspace-member/script new file mode 100644 index 00000000000..febe7abf99a --- /dev/null +++ b/acceptance/localenv/workspace-member/script @@ -0,0 +1,19 @@ +# A real run in a uv workspace member provisions the shared .venv at the +# workspace root and writes the constraint block into the root pyproject.toml. +# The member keeps requires-python and the databricks-connect pin. A fake uv on +# PATH stands in for the network-dependent sync (see acceptance/cmd/psql). +mv fake-uv.sh uv +chmod +x uv +# The script changes folder, so remove the fake uv by its absolute path. +fake_uv="$(pwd)/uv" +cleanup() { + rm -f "$fake_uv" +} +trap cleanup EXIT +export PATH="$(pwd):$PATH" + +cd bundles/a +trace $CLI environments setup-local --serverless-version 4 --output json +cd ../.. +trace cat pyproject.toml +trace cat bundles/a/pyproject.toml diff --git a/acceptance/localenv/workspace-member/test.toml b/acceptance/localenv/workspace-member/test.toml new file mode 100644 index 00000000000..0f62f56b8cc --- /dev/null +++ b/acceptance/localenv/workspace-member/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 = [".venv", "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 d7307a18eb0..937e2fe7de1 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -75,6 +75,9 @@ func renderResult(ctx context.Context, cmd *cobra.Command, res *libslocalenv.Res if res.DryRun { if res.Plan != nil { cmdio.LogString(ctx, "Plan: "+res.Plan.WouldWrite) + if res.Plan.WouldWriteWorkspace != "" { + cmdio.LogString(ctx, "Plan: "+res.Plan.WouldWriteWorkspace+" (workspace root)") + } for _, region := range res.Plan.ChangedRegions { cmdio.LogString(ctx, " changed region: "+region) } @@ -114,6 +117,14 @@ func renderSuccess(ctx context.Context, res *libslocalenv.Result) { pyprojectDetail = "updated (backup: " + res.BackupPath + ")" } cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "pyproject.toml", pyprojectDetail)) + if res.WorkspaceRoot != "" { + rootDetail := "constraints written" + if res.WorkspaceBackupPath != "" { + rootDetail += " (backup: " + res.WorkspaceBackupPath + ")" + } + cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "Workspace root", res.WorkspaceRoot)) + cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "Root pyproject.toml", rootDetail)) + } renderInstalledPythonFallback(ctx, res) cmdio.LogString(ctx, "") diff --git a/cmd/environments/output_test.go b/cmd/environments/output_test.go index e9df52b4bb3..8dea533ac9e 100644 --- a/cmd/environments/output_test.go +++ b/cmd/environments/output_test.go @@ -154,3 +154,39 @@ func TestActivateHint(t *testing.T) { assert.Equal(t, "source .venv/bin/activate", hint) } } + +func TestRenderSuccessWorkspaceMember(t *testing.T) { + res := libslocalenv.NewResult() + res.OK = true + res.Resolved = &libslocalenv.ResolvedInfo{PythonVersion: "3.12"} + res.VenvPath = "../../.venv" + res.WorkspaceRoot = "/repo" + res.WorkspaceBackupPath = "/repo/pyproject.toml.bak" + + out := renderText(t, res, nil) + + assert.Contains(t, out, "Workspace root") + assert.Contains(t, out, "/repo") + assert.Contains(t, out, "Root pyproject.toml") + assert.Contains(t, out, "constraints written (backup: /repo/pyproject.toml.bak)") +} + +func TestRenderSuccessStandaloneOmitsWorkspaceRows(t *testing.T) { + res := libslocalenv.NewResult() + res.OK = true + res.VenvPath = ".venv" + + out := renderText(t, res, nil) + + assert.NotContains(t, out, "Workspace root") + assert.NotContains(t, out, "Root pyproject.toml") +} + +func TestRenderDryRunWorkspaceMember(t *testing.T) { + res := libslocalenv.NewResult() + res.OK = true + res.DryRun = true + res.Plan = &libslocalenv.Plan{WouldWrite: "/repo/a/pyproject.toml", WouldWriteWorkspace: "/repo/pyproject.toml"} + out := renderText(t, res, nil) + assert.Contains(t, out, "Plan: /repo/pyproject.toml (workspace root)") +} diff --git a/cmd/environments/sync.go b/cmd/environments/sync.go index 779fea38182..9b97925164c 100644 --- a/cmd/environments/sync.go +++ b/cmd/environments/sync.go @@ -22,7 +22,9 @@ func newSetupLocalCommand() *cobra.Command { Short: "Set up a local Python environment that matches your Databricks compute", Long: `Set up a local Python environment that matches a Databricks cluster or serverless version, so code you run on your machine behaves the same as it does on Databricks. -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.`, +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.`, Example: ` # Match a serverless version databricks environments setup-local --serverless-version 5 diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index 29f722d7750..b38ab16269c 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -34,6 +34,7 @@ func (fakePM) EnsureAvailable(context.Context) (string, error) { return "fake 1. func (fakePM) EnsurePython(_ context.Context, minor string) (PythonSelection, error) { return PythonSelection{Executable: minor, Resolution: PythonResolutionUVInstallSucceeded}, nil } + func (f fakePM) WorkspaceRoot(_ context.Context, projectDir string) (string, error) { if f.root != "" { return f.root, nil diff --git a/libs/localenv/workspace.go b/libs/localenv/workspace.go index 740702b415f..71e16cabd2e 100644 --- a/libs/localenv/workspace.go +++ b/libs/localenv/workspace.go @@ -25,27 +25,32 @@ func (p *Pipeline) discoverWorkspaceRoot(ctx context.Context) string { log.Debugf(ctx, CommandName+": workspace discovery failed, using the project folder: %v", err) return "" } - same, err := samePath(root, p.ProjectDir) + // uv prints the symlink-resolved root, while ProjectDir comes from the shell + // (on macOS, /var vs /private/var). Compare and join in resolved form, then + // express the root relative to ProjectDir so paths derived from it (the venv, + // the root pyproject.toml) share ProjectDir's form. + rel, err := resolvedRel(p.ProjectDir, root) if err != nil { log.Debugf(ctx, CommandName+": cannot compare workspace root %s with the project folder: %v", filepath.ToSlash(root), err) return "" } - if same { + if rel == "." { return "" } - return root + return filepath.Join(p.ProjectDir, rel) } -// samePath reports whether a and b name the same folder after resolving -// symlinks (macOS temp folders live under /var, a symlink to /private/var). -func samePath(a, b string) (bool, error) { - ra, err := filepath.EvalSymlinks(a) +// resolvedRel returns the path of target relative to base after resolving +// symlinks in both (macOS temp folders live under /var, a symlink to +// /private/var). It is "." when both name the same folder. +func resolvedRel(base, target string) (string, error) { + rb, err := filepath.EvalSymlinks(base) if err != nil { - return false, err + return "", err } - rb, err := filepath.EvalSymlinks(b) + rt, err := filepath.EvalSymlinks(target) if err != nil { - return false, err + return "", err } - return ra == rb, nil + return filepath.Rel(rb, rt) } diff --git a/libs/localenv/workspace_test.go b/libs/localenv/workspace_test.go index de5fc82aee2..847a7cefddc 100644 --- a/libs/localenv/workspace_test.go +++ b/libs/localenv/workspace_test.go @@ -194,3 +194,19 @@ func TestPipelineWorkspaceRootNotWritable(t *testing.T) { require.NoError(t, err) assert.Equal(t, memberBefore, memberAfter) } + +func TestDiscoverWorkspaceRootKeepsProjectPathForm(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need extra privileges on Windows") + } + root, _ := memberLayout(t) + link := filepath.Join(t.TempDir(), "link") + require.NoError(t, os.Symlink(root, link)) + resolved, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + // uv prints the symlink-resolved root; ProjectDir reaches the member through + // the link. The root must come back in ProjectDir's form so the venv path + // relative to the project stays "../../.venv". + p := &Pipeline{ProjectDir: filepath.Join(link, "bundles", "a"), PM: rootPM{root: resolved}} + assert.Equal(t, link, p.discoverWorkspaceRoot(t.Context())) +} From 428f359a4d71df3cb344ded50afb088457a2c4df Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 13:20:43 +0200 Subject: [PATCH 04/17] localenv: add an integration test for a uv workspace member Co-authored-by: Isaac --- libs/localenv/provision_integration_test.go | 46 +++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/libs/localenv/provision_integration_test.go b/libs/localenv/provision_integration_test.go index 71420c20a49..346c8447163 100644 --- a/libs/localenv/provision_integration_test.go +++ b/libs/localenv/provision_integration_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "os" "path/filepath" + "regexp" + "strconv" "testing" "github.com/databricks/cli/libs/env" @@ -105,6 +107,50 @@ 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) + root := t.TempDir() + for _, m := range []string{"a", "b"} { + dir := filepath.Join(root, "bundles", m) + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, pyprojectFile), + []byte("[project]\nname = \""+m+"\"\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)) + 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(realProvisionCtx(t)) + require.NoError(t, err) + assert.Equal(t, "../../.venv", res.VenvPath) + assert.FileExists(t, venvPython(filepath.Join(root, venvDir))) + assert.NoDirExists(t, filepath.Join(member, venvDir)) + + rootPyproject, err := os.ReadFile(filepath.Join(root, pyprojectFile)) + require.NoError(t, err) + assert.Contains(t, string(rootPyproject), `"pyarrow<19"`) + + lock, err := os.ReadFile(filepath.Join(root, "uv.lock")) + require.NoError(t, err) + m := regexp.MustCompile(`name = "pyarrow"\nversion = "(\d+)\.`).FindSubmatch(lock) + require.NotNil(t, m, "uv.lock has no pyarrow entry") + major, err := strconv.Atoi(string(m[1])) + require.NoError(t, err) + assert.Less(t, major, 19, "the root constraint pyarrow<19 must apply") +} + // 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 From c154c9822a01836c530d25aef83b44908625957e Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 13:27:00 +0200 Subject: [PATCH 05/17] localenv: keep the workspace root and venvPath correct through symlinks Co-authored-by: Isaac --- libs/localenv/pipeline.go | 9 +++-- libs/localenv/workspace.go | 29 ++++++++------ libs/localenv/workspace_test.go | 70 ++++++++++++++++++++++++++++++--- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index fef28b19b1d..b38d6764859 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -207,10 +207,13 @@ func (p *Pipeline) run(ctx context.Context) error { // creates it, and keeps its constraint block in the root pyproject.toml, the // only file uv reads constraint-dependencies from. // https://docs.astral.sh/uv/concepts/projects/workspaces/ - p.workspaceRoot = p.discoverWorkspaceRoot(ctx) + var rootRel string + p.workspaceRoot, rootRel = p.discoverWorkspaceRoot(ctx) venvBase := p.ProjectDir + p.venvRel = venvDir if p.workspaceRoot != "" { venvBase = p.workspaceRoot + p.venvRel = filepath.Join(rootRel, venvDir) p.res.WorkspaceRoot = filepath.ToSlash(p.workspaceRoot) if !p.Check { if err := ensureWritable(p.workspaceRoot); err != nil { @@ -219,7 +222,6 @@ func (p *Pipeline) run(ctx context.Context) error { } } p.venv = filepath.Join(venvBase, venvDir) - p.venvRel = venvDir p.markOK(PhasePreflight, detail) // Phase: resolve — compute target → environment key. @@ -743,7 +745,8 @@ func (p *Pipeline) validate(ctx context.Context, expectedPyMinor, dbcPin string, // 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". + // ".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 { if managed { diff --git a/libs/localenv/workspace.go b/libs/localenv/workspace.go index 71e16cabd2e..84f1c38ca56 100644 --- a/libs/localenv/workspace.go +++ b/libs/localenv/workspace.go @@ -8,7 +8,9 @@ import ( ) // discoverWorkspaceRoot returns the uv workspace root when ProjectDir is a -// workspace member, or "" when it is not (a standalone project, the root itself, +// workspace member, and the root relative to ProjectDir between the +// symlink-resolved folders (how the OS resolves a relative path), or "" for both +// when it is not (a standalone project, the root itself, // or a project uv cannot place). A folder with no pyproject.toml yet (greenfield) // is not a member: uv would walk up to a parent project, but the file this // command creates is not listed in any workspace. @@ -16,28 +18,33 @@ import ( // A discovery failure (uv older than 0.9.9, uv not found under --dry-run, a // malformed workspace) keeps today's behavior. A malformed workspace still fails // later in uv sync, with uv's own message. -func (p *Pipeline) discoverWorkspaceRoot(ctx context.Context) string { +func (p *Pipeline) discoverWorkspaceRoot(ctx context.Context) (root, rel string) { if !fileExists(p.pyprojectPath()) { - return "" + return "", "" } root, err := p.PM.WorkspaceRoot(ctx, p.ProjectDir) if err != nil { log.Debugf(ctx, CommandName+": workspace discovery failed, using the project folder: %v", err) - return "" + return "", "" } // uv prints the symlink-resolved root, while ProjectDir comes from the shell - // (on macOS, /var vs /private/var). Compare and join in resolved form, then - // express the root relative to ProjectDir so paths derived from it (the venv, - // the root pyproject.toml) share ProjectDir's form. - rel, err := resolvedRel(p.ProjectDir, root) + // (on macOS, /var vs /private/var). Compare in resolved form. + rel, err = resolvedRel(p.ProjectDir, root) if err != nil { log.Debugf(ctx, CommandName+": cannot compare workspace root %s with the project folder: %v", filepath.ToSlash(root), err) - return "" + return "", "" } if rel == "." { - return "" + return "", "" } - return filepath.Join(p.ProjectDir, rel) + // Prefer the root in ProjectDir's form, so the paths the command reports + // match the folder the user ran it from. That form is only valid when no + // symlink sits between the root and ProjectDir; otherwise use uv's root. + joined := filepath.Join(p.ProjectDir, rel) + if back, err := resolvedRel(joined, root); err == nil && back == "." { + return joined, rel + } + return root, rel } // resolvedRel returns the path of target relative to base after resolving diff --git a/libs/localenv/workspace_test.go b/libs/localenv/workspace_test.go index 847a7cefddc..47ede034281 100644 --- a/libs/localenv/workspace_test.go +++ b/libs/localenv/workspace_test.go @@ -34,13 +34,15 @@ func memberLayout(t *testing.T) (root, member string) { func TestDiscoverWorkspaceRootMember(t *testing.T) { root, member := memberLayout(t) p := &Pipeline{ProjectDir: member, PM: rootPM{root: root}} - assert.Equal(t, root, p.discoverWorkspaceRoot(t.Context())) + got, _ := p.discoverWorkspaceRoot(t.Context()) + assert.Equal(t, root, got) } func TestDiscoverWorkspaceRootStandalone(t *testing.T) { _, member := memberLayout(t) p := &Pipeline{ProjectDir: member, PM: rootPM{root: member}} - assert.Empty(t, p.discoverWorkspaceRoot(t.Context())) + got, _ := p.discoverWorkspaceRoot(t.Context()) + assert.Empty(t, got) } func TestDiscoverWorkspaceRootSymlinkedSameDir(t *testing.T) { @@ -52,7 +54,8 @@ func TestDiscoverWorkspaceRootSymlinkedSameDir(t *testing.T) { require.NoError(t, os.Symlink(member, link)) // uv prints the resolved path; ProjectDir is the symlinked one. p := &Pipeline{ProjectDir: link, PM: rootPM{root: member}} - assert.Empty(t, p.discoverWorkspaceRoot(t.Context())) + got, _ := p.discoverWorkspaceRoot(t.Context()) + assert.Empty(t, got) } func TestDiscoverWorkspaceRootSkipsGreenfield(t *testing.T) { @@ -60,13 +63,15 @@ func TestDiscoverWorkspaceRootSkipsGreenfield(t *testing.T) { member := filepath.Join(root, "new") require.NoError(t, os.MkdirAll(member, 0o755)) p := &Pipeline{ProjectDir: member, PM: rootPM{root: root}} - assert.Empty(t, p.discoverWorkspaceRoot(t.Context())) + got, _ := p.discoverWorkspaceRoot(t.Context()) + assert.Empty(t, got) } func TestDiscoverWorkspaceRootUvFailure(t *testing.T) { _, member := memberLayout(t) p := &Pipeline{ProjectDir: member, PM: rootPM{err: errors.New("unrecognized subcommand 'workspace'")}} - assert.Empty(t, p.discoverWorkspaceRoot(t.Context())) + got, _ := p.discoverWorkspaceRoot(t.Context()) + assert.Empty(t, got) } func TestWorkspaceDirArgs(t *testing.T) { @@ -208,5 +213,58 @@ func TestDiscoverWorkspaceRootKeepsProjectPathForm(t *testing.T) { // the link. The root must come back in ProjectDir's form so the venv path // relative to the project stays "../../.venv". p := &Pipeline{ProjectDir: filepath.Join(link, "bundles", "a"), PM: rootPM{root: resolved}} - assert.Equal(t, link, p.discoverWorkspaceRoot(t.Context())) + got, _ := p.discoverWorkspaceRoot(t.Context()) + assert.Equal(t, link, got) +} + +// symlinkBelowRootLayout reaches the member through a link to the workspace's +// bundles folder (link -> /bundles), so the link sits below the root. +func symlinkBelowRootLayout(t *testing.T) (resolvedRoot, projectDir string) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need extra privileges on Windows") + } + root, _ := memberLayout(t) + link := filepath.Join(t.TempDir(), "link") + require.NoError(t, os.Symlink(filepath.Join(root, "bundles"), link)) + resolvedRoot, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + return resolvedRoot, filepath.Join(link, "a") +} + +func TestDiscoverWorkspaceRootSymlinkBelowRoot(t *testing.T) { + resolvedRoot, projectDir := symlinkBelowRootLayout(t) + // Joining "../.." onto the link path would give the link's parent, not the + // root; the root must still name the real workspace root. + p := &Pipeline{ProjectDir: projectDir, PM: rootPM{root: resolvedRoot}} + got, _ := p.discoverWorkspaceRoot(t.Context()) + rel, err := resolvedRel(got, resolvedRoot) + require.NoError(t, err) + assert.Equal(t, ".", rel, "discovered %s, want %s", got, resolvedRoot) +} + +func TestPipelineWorkspaceMemberVenvPathWithResolvedUvRoot(t *testing.T) { + // Real uv prints the symlink-resolved root (macOS /private/var). + p, root, _ := newMemberPipeline(t) + resolved, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + p.PM = fakePM{py: "3.12", dbc: "17.2.0", root: resolved} + res, err := p.Run(t.Context()) + require.NoError(t, err) + assert.Equal(t, "../../.venv", res.VenvPath) +} + +func TestPipelineWorkspaceMemberSymlinkBelowRoot(t *testing.T) { + resolvedRoot, projectDir := symlinkBelowRootLayout(t) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, pyprojectFile), + []byte("[project]\nname = \"a\"\nversion = \"0.1.0\"\nrequires-python = \">=3.10\"\n"), 0o644)) + p := newServerlessTestPipeline(t, projectDir) + p.PM = fakePM{py: "3.12", dbc: "17.2.0", root: resolvedRoot} + res, err := p.Run(t.Context()) + require.NoError(t, err) + // Relative to the member's real folder (/bundles/a), which is what the + // OS resolves a relative path against. + assert.Equal(t, "../../.venv", res.VenvPath) + rootBytes, err := os.ReadFile(filepath.Join(resolvedRoot, pyprojectFile)) + require.NoError(t, err) + assert.Contains(t, string(rootBytes), "constraint-dependencies") } From e158b2440c45f7a812c9881bc6cecb094770e042 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 13:27:44 +0200 Subject: [PATCH 06/17] environments: say when setup-local leaves the workspace root unchanged Co-authored-by: Isaac --- cmd/environments/output.go | 6 ++++-- cmd/environments/output_test.go | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/cmd/environments/output.go b/cmd/environments/output.go index 937e2fe7de1..eec6af2389f 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -118,9 +118,11 @@ func renderSuccess(ctx context.Context, res *libslocalenv.Result) { } cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "pyproject.toml", pyprojectDetail)) if res.WorkspaceRoot != "" { - rootDetail := "constraints written" + // The root file changes only when its constraint block changes, and every + // change writes a backup; --no-constraints and a no-op re-run leave it alone. + rootDetail := "unchanged" if res.WorkspaceBackupPath != "" { - rootDetail += " (backup: " + res.WorkspaceBackupPath + ")" + rootDetail = "constraints updated (backup: " + res.WorkspaceBackupPath + ")" } cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "Workspace root", res.WorkspaceRoot)) cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "Root pyproject.toml", rootDetail)) diff --git a/cmd/environments/output_test.go b/cmd/environments/output_test.go index 8dea533ac9e..a7053fb9496 100644 --- a/cmd/environments/output_test.go +++ b/cmd/environments/output_test.go @@ -168,7 +168,7 @@ func TestRenderSuccessWorkspaceMember(t *testing.T) { assert.Contains(t, out, "Workspace root") assert.Contains(t, out, "/repo") assert.Contains(t, out, "Root pyproject.toml") - assert.Contains(t, out, "constraints written (backup: /repo/pyproject.toml.bak)") + assert.Contains(t, out, "constraints updated (backup: /repo/pyproject.toml.bak)") } func TestRenderSuccessStandaloneOmitsWorkspaceRows(t *testing.T) { @@ -190,3 +190,17 @@ func TestRenderDryRunWorkspaceMember(t *testing.T) { out := renderText(t, res, nil) assert.Contains(t, out, "Plan: /repo/pyproject.toml (workspace root)") } + +func TestRenderSuccessWorkspaceMemberUnchangedRoot(t *testing.T) { + // --no-constraints or an idempotent re-run: the root file was not changed. + res := libslocalenv.NewResult() + res.OK = true + res.VenvPath = "../../.venv" + res.WorkspaceRoot = "/repo" + + out := renderText(t, res, nil) + + assert.Contains(t, out, "Root pyproject.toml") + assert.Contains(t, out, "unchanged") + assert.NotContains(t, out, "constraints updated") +} From 35c5a6a1b4d1a8989be98cadfb1af12bdfb2d6d6 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 13:29:12 +0200 Subject: [PATCH 07/17] localenv: remove a stale managed constraint block from a workspace member Co-authored-by: Isaac --- libs/localenv/merge.go | 28 ++++++++++++++++++++++++++++ libs/localenv/merge_test.go | 26 ++++++++++++++++++++++++++ libs/localenv/pipeline.go | 3 ++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go index f945e2424de..12ce2e18df1 100644 --- a/libs/localenv/merge.go +++ b/libs/localenv/merge.go @@ -175,6 +175,17 @@ func MergeManaged(target []byte, c Constraints, opts MergeOptions) (merged []byt regions = append(regions, regionToolUv) } } + + // A workspace member's pins live at the root. A managed block left in the + // member by an earlier run has no effect (uv ignores it), so remove it + // rather than keep pins that disagree with what uv applies. + if !opts.SkipConstraints && opts.SkipToolUv { + var removed bool + lines, removed = removeManagedToolUv(lines) + if removed { + regions = append(regions, regionToolUv) + } + } return lines, nil }) if err != nil { @@ -980,6 +991,23 @@ func mergeToolUv(lines, deps []string) ([]string, bool) { return appendManagedBlock(lines, renderToolUvBlock(deps, true)), true } +// removeManagedToolUv removes the marker-bracketed managed [tool.uv] block, and +// the one blank line appendManagedBlock puts before a standalone block. A +// header-less block inside a user [tool.uv] table leaves the table's own keys. +func removeManagedToolUv(lines []string) ([]string, bool) { + start, stop, found := markerBounds(lines) + if !found { + return lines, false + } + if start > 0 && strings.TrimSpace(lines[start-1]) == "" { + start-- + } + out := make([]string, 0, len(lines)-(stop-start+1)) + out = append(out, lines[:start]...) + out = append(out, lines[stop+1:]...) + return out, true +} + // markerAttachedToToolUv reports whether the managed marker region beginning at // index start sits inside an existing [tool.uv] table — i.e. the nearest table // header above it is [tool.uv]. When true, the managed block must omit its own diff --git a/libs/localenv/merge_test.go b/libs/localenv/merge_test.go index 2cc5b20fcc2..09109ca4287 100644 --- a/libs/localenv/merge_test.go +++ b/libs/localenv/merge_test.go @@ -1303,3 +1303,29 @@ func TestMergeToolUvBlockKeepsCRLF(t *testing.T) { require.NoError(t, err) assert.NotContains(t, strings.ReplaceAll(string(out), "\r\n", ""), "\n") } + +func TestMergeManagedSkipToolUvRemovesStaleStandaloneBlock(t *testing.T) { + // A run before workspace support wrote the managed block into the member, + // where uv ignores it. The member merge removes it; the root holds the pins. + in := "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n" + + managedMarkerStart + "\n[tool.uv]\nconstraint-dependencies = [\n \"x==1\",\n]\n" + managedMarkerEnd + "\n" + c := Constraints{RequiresPython: ">=3.12"} + out, regions, err := MergeManaged([]byte(in), c, MergeOptions{SkipToolUv: true, SkipDBConnect: true}) + require.NoError(t, err) + assert.Equal(t, "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n", string(out)) + assert.Contains(t, regions, regionToolUv) + + again, regions2, err := MergeManaged(out, c, MergeOptions{SkipToolUv: true, SkipDBConnect: true}) + require.NoError(t, err) + assert.Equal(t, out, again) + assert.NotContains(t, regions2, regionToolUv) +} + +func TestMergeManagedSkipToolUvRemovesStaleBlockKeepsUserTable(t *testing.T) { + in := "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n[tool.uv]\npackage = false\n" + + managedMarkerStart + "\nconstraint-dependencies = [\n \"x==1\",\n]\n" + managedMarkerEnd + "\n" + c := Constraints{RequiresPython: ">=3.12"} + out, _, err := MergeManaged([]byte(in), c, MergeOptions{SkipToolUv: true, SkipDBConnect: true}) + require.NoError(t, err) + assert.Equal(t, "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n[tool.uv]\npackage = false\n", string(out)) +} diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index b38d6764859..9e31f302305 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "time" @@ -483,7 +484,7 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, if err != nil { return nil, nil, greenfield, err } - if root != nil && !bytes.Equal(root.base, root.merged) { + if root != nil && !bytes.Equal(root.base, root.merged) && !slices.Contains(changedRegions, regionToolUv) { changedRegions = append(changedRegions, regionToolUv) } From f1b8dc2ebeca42191b459b7d61a519024f9a2c09 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 13:31:53 +0200 Subject: [PATCH 08/17] Add the PR link to the changelog fragment Co-authored-by: Isaac --- .nextchanges/cli/setup-local-uv-workspace.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nextchanges/cli/setup-local-uv-workspace.md b/.nextchanges/cli/setup-local-uv-workspace.md index 48ef55e036c..6c692161aa6 100644 --- a/.nextchanges/cli/setup-local-uv-workspace.md +++ b/.nextchanges/cli/setup-local-uv-workspace.md @@ -1 +1 @@ -* `databricks environments setup-local` now supports uv workspace members: it uses the shared `.venv` at the workspace root and writes the dependency constraints to the root `pyproject.toml`. +* `databricks environments setup-local` now supports uv workspace members: it uses the shared `.venv` at the workspace root and writes the dependency constraints to the root `pyproject.toml`. ([#7018](https://github.com/databricks/cli/pull/7018)) From 656a53a937874342f2886f86c6f12cc78f34a0d4 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 14:12:47 +0200 Subject: [PATCH 09/17] localenv: compare the workspace root by folder identity, not by name uv prints the root in the on-disk letter case, while the shell keeps the case the user typed, so a name comparison treated a standalone project on a case-insensitive file system as a member of itself. Co-authored-by: Isaac --- libs/localenv/pipeline_test.go | 11 +++- libs/localenv/workspace.go | 86 ++++++++++++++++++------------ libs/localenv/workspace_test.go | 93 +++++++++++++++++++-------------- 3 files changed, 118 insertions(+), 72 deletions(-) diff --git a/libs/localenv/pipeline_test.go b/libs/localenv/pipeline_test.go index b38ab16269c..e1c2e735e28 100644 --- a/libs/localenv/pipeline_test.go +++ b/libs/localenv/pipeline_test.go @@ -27,7 +27,13 @@ const cancelPMStderr = "error: no solution found: databricks-connect==17.2 confl // below — a plain ">= 0" assertion would also hold for the unset field. const fetchDelay = 25 * time.Millisecond -type fakePM struct{ py, dbc, pyspark, dbcImportErr, root string } +type fakePM struct { + py, dbc, pyspark, dbcImportErr string + // root and rootErr are what WorkspaceRoot reports; an empty root means the + // project is not a workspace member. + root string + rootErr error +} func (fakePM) Name() string { return "fake" } func (fakePM) EnsureAvailable(context.Context) (string, error) { return "fake 1.0", nil } @@ -36,6 +42,9 @@ func (fakePM) EnsurePython(_ context.Context, minor string) (PythonSelection, er } func (f fakePM) WorkspaceRoot(_ context.Context, projectDir string) (string, error) { + if f.rootErr != nil { + return "", f.rootErr + } if f.root != "" { return f.root, nil } diff --git a/libs/localenv/workspace.go b/libs/localenv/workspace.go index 84f1c38ca56..a33f65ad991 100644 --- a/libs/localenv/workspace.go +++ b/libs/localenv/workspace.go @@ -2,62 +2,82 @@ package localenv import ( "context" + "os" "path/filepath" "github.com/databricks/cli/libs/log" ) // discoverWorkspaceRoot returns the uv workspace root when ProjectDir is a -// workspace member, and the root relative to ProjectDir between the -// symlink-resolved folders (how the OS resolves a relative path), or "" for both -// when it is not (a standalone project, the root itself, -// or a project uv cannot place). A folder with no pyproject.toml yet (greenfield) -// is not a member: uv would walk up to a parent project, but the file this -// command creates is not listed in any workspace. +// workspace member, and the root relative to ProjectDir (for example "../.."). +// It returns "" for both when ProjectDir is not a member: a standalone project, +// the root itself, or a project uv cannot place. +// +// A folder with no pyproject.toml yet (greenfield) is not treated as a member, +// even when a members glob of a parent workspace matches it: the first run sets +// it up as a standalone project, and a re-run finds it as a member. // // A discovery failure (uv older than 0.9.9, uv not found under --dry-run, a -// malformed workspace) keeps today's behavior. A malformed workspace still fails -// later in uv sync, with uv's own message. +// malformed workspace) keeps the standalone behavior. A malformed workspace +// still fails later in uv sync, with uv's own message. func (p *Pipeline) discoverWorkspaceRoot(ctx context.Context) (root, rel string) { if !fileExists(p.pyprojectPath()) { return "", "" } root, err := p.PM.WorkspaceRoot(ctx, p.ProjectDir) if err != nil { - log.Debugf(ctx, CommandName+": workspace discovery failed, using the project folder: %v", err) + log.Debugf(ctx, CommandName+": workspace discovery failed, using the project folder: %v: %s", err, uvStderr(err)) return "", "" } - // uv prints the symlink-resolved root, while ProjectDir comes from the shell - // (on macOS, /var vs /private/var). Compare in resolved form. - rel, err = resolvedRel(p.ProjectDir, root) - if err != nil { - log.Debugf(ctx, CommandName+": cannot compare workspace root %s with the project folder: %v", filepath.ToSlash(root), err) - return "", "" + // uv prints the root with symlinks resolved and in the on-disk letter case, + // while ProjectDir keeps the form the shell gave (on macOS, /var vs + // /private/var, and any letter case). So compare folders by identity. Prefer + // the root in ProjectDir's form, so the paths the command reports match the + // folder the user ran it from. If a symlink sits between the root and + // ProjectDir, only the symlink-resolved ProjectDir reaches the root; then use + // uv's root, with the relative path the OS resolves. + if ancestor, levels, ok := sameFolderAncestor(p.ProjectDir, root); ok { + if levels == 0 { + return "", "" + } + return ancestor, upLevels(levels) } - if rel == "." { + resolved, err := filepath.EvalSymlinks(p.ProjectDir) + if err != nil { + log.Debugf(ctx, CommandName+": cannot resolve the project folder: %v", err) return "", "" } - // Prefer the root in ProjectDir's form, so the paths the command reports - // match the folder the user ran it from. That form is only valid when no - // symlink sits between the root and ProjectDir; otherwise use uv's root. - joined := filepath.Join(p.ProjectDir, rel) - if back, err := resolvedRel(joined, root); err == nil && back == "." { - return joined, rel + if _, levels, ok := sameFolderAncestor(resolved, root); ok && levels > 0 { + return root, upLevels(levels) } - return root, rel + log.Debugf(ctx, CommandName+": workspace root %s is not a parent of the project folder", filepath.ToSlash(root)) + return "", "" } -// resolvedRel returns the path of target relative to base after resolving -// symlinks in both (macOS temp folders live under /var, a symlink to -// /private/var). It is "." when both name the same folder. -func resolvedRel(base, target string) (string, error) { - rb, err := filepath.EvalSymlinks(base) +// sameFolderAncestor returns the first of dir and its parents that is the same +// folder as target (by file identity, not by name), and how many levels above +// dir it is. +func sameFolderAncestor(dir, target string) (ancestor string, levels int, ok bool) { + targetInfo, err := os.Stat(target) if err != nil { - return "", err + return "", 0, false } - rt, err := filepath.EvalSymlinks(target) - if err != nil { - return "", err + for d := dir; ; d = filepath.Dir(d) { + if info, err := os.Stat(d); err == nil && os.SameFile(info, targetInfo) { + return d, levels, true + } + if filepath.Dir(d) == d { + return "", 0, false + } + levels++ + } +} + +// upLevels returns the relative path n folders up, for example "../.." for 2. +func upLevels(n int) string { + parts := make([]string, n) + for i := range parts { + parts[i] = ".." } - return filepath.Rel(rb, rt) + return filepath.Join(parts...) } diff --git a/libs/localenv/workspace_test.go b/libs/localenv/workspace_test.go index 47ede034281..893f83b945b 100644 --- a/libs/localenv/workspace_test.go +++ b/libs/localenv/workspace_test.go @@ -1,7 +1,6 @@ package localenv import ( - "context" "errors" "os" "path/filepath" @@ -13,16 +12,9 @@ import ( "github.com/stretchr/testify/require" ) -// rootPM reports a fixed workspace root (or error) from WorkspaceRoot. -type rootPM struct { - fakePM - root string - err error -} - -func (f rootPM) WorkspaceRoot(context.Context, string) (string, error) { return f.root, f.err } - +// memberLayout creates a uv workspace root with one member, bundles/a. func memberLayout(t *testing.T) (root, member string) { + t.Helper() root = t.TempDir() member = filepath.Join(root, "bundles", "a") require.NoError(t, os.MkdirAll(member, 0o755)) @@ -31,18 +23,32 @@ func memberLayout(t *testing.T) (root, member string) { return root, member } -func TestDiscoverWorkspaceRootMember(t *testing.T) { +func TestDiscoverWorkspaceRoot(t *testing.T) { root, member := memberLayout(t) - p := &Pipeline{ProjectDir: member, PM: rootPM{root: root}} - got, _ := p.discoverWorkspaceRoot(t.Context()) - assert.Equal(t, root, got) -} + greenfield := filepath.Join(root, "new") + require.NoError(t, os.MkdirAll(greenfield, 0o755)) -func TestDiscoverWorkspaceRootStandalone(t *testing.T) { - _, member := memberLayout(t) - p := &Pipeline{ProjectDir: member, PM: rootPM{root: member}} - got, _ := p.discoverWorkspaceRoot(t.Context()) - assert.Empty(t, got) + tests := []struct { + name string + projectDir string + pm fakePM + wantRoot string + wantRel string + }{ + {"member", member, fakePM{root: root}, root, filepath.Join("..", "..")}, + {"standalone", member, fakePM{root: member}, "", ""}, + // The created pyproject.toml is not a member yet; see discoverWorkspaceRoot. + {"greenfield", greenfield, fakePM{root: root}, "", ""}, + {"uv failure", member, fakePM{rootErr: errors.New("unrecognized subcommand 'workspace'")}, "", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := &Pipeline{ProjectDir: tc.projectDir, PM: tc.pm} + gotRoot, gotRel := p.discoverWorkspaceRoot(t.Context()) + assert.Equal(t, tc.wantRoot, gotRoot) + assert.Equal(t, tc.wantRel, gotRel) + }) + } } func TestDiscoverWorkspaceRootSymlinkedSameDir(t *testing.T) { @@ -53,32 +59,42 @@ func TestDiscoverWorkspaceRootSymlinkedSameDir(t *testing.T) { link := filepath.Join(t.TempDir(), "link") require.NoError(t, os.Symlink(member, link)) // uv prints the resolved path; ProjectDir is the symlinked one. - p := &Pipeline{ProjectDir: link, PM: rootPM{root: member}} + p := &Pipeline{ProjectDir: link, PM: fakePM{root: member}} got, _ := p.discoverWorkspaceRoot(t.Context()) assert.Empty(t, got) } -func TestDiscoverWorkspaceRootSkipsGreenfield(t *testing.T) { - root := t.TempDir() - member := filepath.Join(root, "new") - require.NoError(t, os.MkdirAll(member, 0o755)) - p := &Pipeline{ProjectDir: member, PM: rootPM{root: root}} - got, _ := p.discoverWorkspaceRoot(t.Context()) - assert.Empty(t, got) +// caseFoldedPath returns path with its last element in upper case, and skips +// the test on a case-sensitive file system, where that path does not exist. +func caseFoldedPath(t *testing.T, path string) string { + t.Helper() + folded := filepath.Join(filepath.Dir(path), strings.ToUpper(filepath.Base(path))) + if _, err := os.Stat(folded); err != nil { + t.Skip("the file system is case-sensitive") + } + return folded } -func TestDiscoverWorkspaceRootUvFailure(t *testing.T) { +func TestDiscoverWorkspaceRootCaseMismatchStandalone(t *testing.T) { + // The shell keeps the letter case the user typed; uv prints the case on disk. _, member := memberLayout(t) - p := &Pipeline{ProjectDir: member, PM: rootPM{err: errors.New("unrecognized subcommand 'workspace'")}} + p := &Pipeline{ProjectDir: caseFoldedPath(t, member), PM: fakePM{root: member}} got, _ := p.discoverWorkspaceRoot(t.Context()) assert.Empty(t, got) } -func TestWorkspaceDirArgs(t *testing.T) { - assert.Equal(t, []string{"workspace", "dir"}, (&uvManager{}).workspaceDirArgs()) +func TestDiscoverWorkspaceRootCaseMismatchMember(t *testing.T) { + root, _ := memberLayout(t) + foldedRoot := caseFoldedPath(t, root) + p := &Pipeline{ProjectDir: filepath.Join(foldedRoot, "bundles", "a"), PM: fakePM{root: root}} + gotRoot, gotRel := p.discoverWorkspaceRoot(t.Context()) + assert.Equal(t, foldedRoot, gotRoot) + assert.Equal(t, filepath.Join("..", ".."), gotRel) } +// newMemberPipeline returns a pipeline for the member of a memberLayout. func newMemberPipeline(t *testing.T) (p *Pipeline, root, member string) { + t.Helper() root, member = memberLayout(t) // The member needs requires-python so the existing merge accepts it. require.NoError(t, os.WriteFile(filepath.Join(member, pyprojectFile), @@ -212,7 +228,7 @@ func TestDiscoverWorkspaceRootKeepsProjectPathForm(t *testing.T) { // uv prints the symlink-resolved root; ProjectDir reaches the member through // the link. The root must come back in ProjectDir's form so the venv path // relative to the project stays "../../.venv". - p := &Pipeline{ProjectDir: filepath.Join(link, "bundles", "a"), PM: rootPM{root: resolved}} + p := &Pipeline{ProjectDir: filepath.Join(link, "bundles", "a"), PM: fakePM{root: resolved}} got, _ := p.discoverWorkspaceRoot(t.Context()) assert.Equal(t, link, got) } @@ -220,6 +236,7 @@ func TestDiscoverWorkspaceRootKeepsProjectPathForm(t *testing.T) { // symlinkBelowRootLayout reaches the member through a link to the workspace's // bundles folder (link -> /bundles), so the link sits below the root. func symlinkBelowRootLayout(t *testing.T) (resolvedRoot, projectDir string) { + t.Helper() if runtime.GOOS == "windows" { t.Skip("symlinks need extra privileges on Windows") } @@ -235,11 +252,11 @@ func TestDiscoverWorkspaceRootSymlinkBelowRoot(t *testing.T) { resolvedRoot, projectDir := symlinkBelowRootLayout(t) // Joining "../.." onto the link path would give the link's parent, not the // root; the root must still name the real workspace root. - p := &Pipeline{ProjectDir: projectDir, PM: rootPM{root: resolvedRoot}} - got, _ := p.discoverWorkspaceRoot(t.Context()) - rel, err := resolvedRel(got, resolvedRoot) - require.NoError(t, err) - assert.Equal(t, ".", rel, "discovered %s, want %s", got, resolvedRoot) + p := &Pipeline{ProjectDir: projectDir, PM: fakePM{root: resolvedRoot}} + gotRoot, gotRel := p.discoverWorkspaceRoot(t.Context()) + assert.Equal(t, resolvedRoot, gotRoot) + // Relative to the member's real folder (/bundles/a). + assert.Equal(t, filepath.Join("..", ".."), gotRel) } func TestPipelineWorkspaceMemberVenvPathWithResolvedUvRoot(t *testing.T) { From c8bad8f86b8f498ba2326f62733d7b164872cad2 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 14:15:01 +0200 Subject: [PATCH 10/17] localenv: keep user [tool.uv] keys and reject a dotted-key workspace root Removing a stale managed block from a member now keeps its [tool.uv] header when user keys follow the block. A workspace root that defines [tool.uv] with dotted keys fails the merge instead of getting a second [tool.uv] table. Co-authored-by: Isaac --- libs/localenv/merge.go | 64 +++++++++++++++++++++++++++++++++++-- libs/localenv/merge_test.go | 33 +++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go index 12ce2e18df1..8c8f69e872e 100644 --- a/libs/localenv/merge.go +++ b/libs/localenv/merge.go @@ -200,11 +200,48 @@ func MergeManaged(target []byte, c Constraints, opts MergeOptions) (merged []byt // table. changed reports whether the block differs from the input. func MergeToolUvBlock(target []byte, deps []string) (merged []byte, changed bool, err error) { merged, err = transformLines(target, func(lines []string) ([]string, error) { + if definesToolUvWithDottedKeys(lines) { + return nil, errDottedToolUv + } var out []string out, changed = mergeToolUv(lines, deps) return out, nil }) - return merged, changed, err + if err != nil { + return nil, false, err + } + return merged, changed, nil +} + +// errDottedToolUv: the file defines the [tool.uv] table with dotted keys, which +// the line-based merge cannot extend. Appending a [tool.uv] header would define +// the table twice, which uv rejects for the whole workspace. +var errDottedToolUv = errors.New("pyproject.toml defines [tool.uv] with dotted keys (for example tool.uv.workspace.members); use a [tool.uv.workspace] table so the constraint block can be added") + +// toolUvDottedTopRe and toolUvDottedToolRe match a dotted key that defines the [tool.uv] table: tool.uv.… +// at the top level, or uv.… inside [tool]. +var ( + toolUvDottedTopRe = regexp.MustCompile(`^\s*tool\s*\.\s*uv\s*[.=]`) + toolUvDottedToolRe = regexp.MustCompile(`^\s*uv\s*[.=]`) +) + +// definesToolUvWithDottedKeys reports whether lines define the [tool.uv] table +// only through dotted keys, with no [tool.uv] header. +func definesToolUvWithDottedKeys(lines []string) bool { + if _, _, ok := tableBounds(lines, "[tool.uv]"); ok { + return false + } + table := "" + for _, line := range lines { + if name := headerName(line); name != "" { + table = name + continue + } + if (table == "" && toolUvDottedTopRe.MatchString(line)) || (table == "[tool]" && toolUvDottedToolRe.MatchString(line)) { + return true + } + } + return false } // transformLines runs fn over target split into lines, with CRLF normalized to @@ -994,20 +1031,41 @@ func mergeToolUv(lines, deps []string) ([]string, bool) { // removeManagedToolUv removes the marker-bracketed managed [tool.uv] block, and // the one blank line appendManagedBlock puts before a standalone block. A // header-less block inside a user [tool.uv] table leaves the table's own keys. +// A standalone block keeps its [tool.uv] header when user keys follow its end +// marker, because those keys belong to that table. func removeManagedToolUv(lines []string) ([]string, bool) { start, stop, found := markerBounds(lines) if !found { return lines, false } - if start > 0 && strings.TrimSpace(lines[start-1]) == "" { + var keep []string + if slices.ContainsFunc(lines[start:stop+1], func(l string) bool { return headerName(l) == "[tool.uv]" }) && + tableBodyHasKeys(lines, stop+1) { + keep = []string{"[tool.uv]"} + } else if start > 0 && strings.TrimSpace(lines[start-1]) == "" { start-- } - out := make([]string, 0, len(lines)-(stop-start+1)) + out := make([]string, 0, len(lines)-(stop-start+1)+len(keep)) out = append(out, lines[:start]...) + out = append(out, keep...) out = append(out, lines[stop+1:]...) return out, true } +// tableBodyHasKeys reports whether the lines from index from up to the next +// table header hold anything other than blank lines and comments. +func tableBodyHasKeys(lines []string, from int) bool { + for _, line := range lines[from:] { + if tableHeaderRe.MatchString(line) { + return false + } + if t := strings.TrimSpace(line); t != "" && !strings.HasPrefix(t, "#") { + return true + } + } + return false +} + // markerAttachedToToolUv reports whether the managed marker region beginning at // index start sits inside an existing [tool.uv] table — i.e. the nearest table // header above it is [tool.uv]. When true, the managed block must omit its own diff --git a/libs/localenv/merge_test.go b/libs/localenv/merge_test.go index 09109ca4287..7ac16630d46 100644 --- a/libs/localenv/merge_test.go +++ b/libs/localenv/merge_test.go @@ -1329,3 +1329,36 @@ func TestMergeManagedSkipToolUvRemovesStaleBlockKeepsUserTable(t *testing.T) { require.NoError(t, err) assert.Equal(t, "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n[tool.uv]\npackage = false\n", string(out)) } + +func TestMergeToolUvBlockRejectsDottedKeyWorkspace(t *testing.T) { + // A dotted key defines the [tool.uv] table, so appending a [tool.uv] header + // would define it twice: invalid TOML that breaks uv for the whole workspace. + in := []byte("tool.uv.workspace.members = [\"bundles/*\"]\n") + _, _, err := MergeToolUvBlock(in, []string{"x==1"}) + assert.Error(t, err) +} + +func TestMergeManagedSkipToolUvKeepsHeaderForTrailingUserKeys(t *testing.T) { + // The user added a key below an earlier standalone block. It belongs to that + // block's [tool.uv] table, so the header must survive the block's removal. + in := "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n" + + managedMarkerStart + "\n[tool.uv]\nconstraint-dependencies = [\n \"x==1\",\n]\n" + managedMarkerEnd + "\npackage = false\n" + c := Constraints{RequiresPython: ">=3.12"} + out, _, err := MergeManaged([]byte(in), c, MergeOptions{SkipToolUv: true, SkipDBConnect: true}) + require.NoError(t, err) + assert.Equal(t, "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n[tool.uv]\npackage = false\n", string(out)) +} + +func TestMergeToolUvBlockAllowsWorkspaceSubtable(t *testing.T) { + // A [tool.uv.workspace] header does not define [tool.uv] itself, so the + // managed [tool.uv] block can follow it (uv accepts this layout). + in := []byte("[tool.uv.workspace]\nmembers = [\"bundles/*\"]\n") + _, _, err := MergeToolUvBlock(in, []string{"x==1"}) + assert.NoError(t, err) +} + +func TestMergeToolUvBlockRejectsDottedKeyInToolTable(t *testing.T) { + in := []byte("[tool]\nuv.workspace.members = [\"bundles/*\"]\n") + _, _, err := MergeToolUvBlock(in, []string{"x==1"}) + assert.ErrorIs(t, err, errDottedToolUv) +} From 2ed7edd745b929089329fec7c0c8278b4c235c01 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 14:21:12 +0200 Subject: [PATCH 11/17] localenv: address review findings for uv workspace members - Warn when the root pins are replaced, also when the user wrote them. - Report diskMutated from both pyproject writes; name the workspace root in root write errors. - Fix the Windows activate hint for a relative venv path. - Say in the help that uv sync removes packages only other members need. - Rename SkipToolUv to ConstraintsAtWorkspaceRoot and set it with the other merge options; test WorkspaceRoot; tidy tests and acceptance scripts. Co-authored-by: Isaac --- acceptance/localenv/help/output.txt | 2 +- .../bundles/a/pyproject.toml | 4 ++ .../output.txt | 4 +- .../pyproject.toml | 5 ++- .../script | 6 +-- .../localenv/workspace-member-check/script | 9 ++-- acceptance/localenv/workspace-member/script | 8 +--- cmd/environments/output.go | 5 ++- cmd/environments/output_test.go | 10 +++++ cmd/environments/sync.go | 2 +- libs/localenv/merge.go | 32 +++++++------- libs/localenv/merge_test.go | 18 ++++---- libs/localenv/pipeline.go | 31 +++++++------- libs/localenv/provision_integration_test.go | 3 ++ libs/localenv/uv_test.go | 28 +++++++++++++ libs/localenv/workspace_test.go | 42 ++++++++++++++----- 16 files changed, 135 insertions(+), 74 deletions(-) create mode 100644 acceptance/localenv/workspace-discovery-unsupported-check/bundles/a/pyproject.toml diff --git a/acceptance/localenv/help/output.txt b/acceptance/localenv/help/output.txt index 96ce3beb021..7303039176b 100644 --- a/acceptance/localenv/help/output.txt +++ b/acceptance/localenv/help/output.txt @@ -2,7 +2,7 @@ Set up a local Python environment that matches a Databricks cluster or serverles 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. +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. Usage: databricks environments setup-local [flags] diff --git a/acceptance/localenv/workspace-discovery-unsupported-check/bundles/a/pyproject.toml b/acceptance/localenv/workspace-discovery-unsupported-check/bundles/a/pyproject.toml new file mode 100644 index 00000000000..962b088a2b0 --- /dev/null +++ b/acceptance/localenv/workspace-discovery-unsupported-check/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-discovery-unsupported-check/output.txt b/acceptance/localenv/workspace-discovery-unsupported-check/output.txt index 3d64de6720a..dcb8fe5bc95 100644 --- a/acceptance/localenv/workspace-discovery-unsupported-check/output.txt +++ b/acceptance/localenv/workspace-discovery-unsupported-check/output.txt @@ -18,8 +18,8 @@ }, "greenfield": false, "plan": { - "wouldWrite": "[TEST_TMP_DIR]/pyproject.toml", - "wouldBackup": "[TEST_TMP_DIR]/pyproject.toml.bak", + "wouldWrite": "[TEST_TMP_DIR]/bundles/a/pyproject.toml", + "wouldBackup": "[TEST_TMP_DIR]/bundles/a/pyproject.toml.bak", "wouldInstallPython": "3.12", "diff": "--- pyproject.toml\n+++ pyproject.toml.new\n@@ -1,4 +1,20 @@\n [project]\n name = \"a\"\n version = \"0.1.0\"\n-requires-python = \"\u003e=3.10\"\n+requires-python = \"\u003e=3.12\"\n+\n+[dependency-groups]\n+dev = [\n+ \"databricks-connect~=17.2.0\",\n+]\n+\n+[tool.databricks.environment]\n+environment_version = \"4\"\n+\n+# managed by databricks environments setup-local — do not edit\n+[tool.uv]\n+constraint-dependencies = [\n+ \"pyarrow\u003c19\",\n+ \"pandas\u003c3\",\n+]\n+# end managed by databricks environments setup-local\n" }, diff --git a/acceptance/localenv/workspace-discovery-unsupported-check/pyproject.toml b/acceptance/localenv/workspace-discovery-unsupported-check/pyproject.toml index 962b088a2b0..7a29f61f24a 100644 --- a/acceptance/localenv/workspace-discovery-unsupported-check/pyproject.toml +++ b/acceptance/localenv/workspace-discovery-unsupported-check/pyproject.toml @@ -1,4 +1,7 @@ [project] -name = "a" +name = "root" version = "0.1.0" requires-python = ">=3.10" + +[tool.uv.workspace] +members = ["bundles/*"] diff --git a/acceptance/localenv/workspace-discovery-unsupported-check/script b/acceptance/localenv/workspace-discovery-unsupported-check/script index dd535bf55a3..f2c37466149 100644 --- a/acceptance/localenv/workspace-discovery-unsupported-check/script +++ b/acceptance/localenv/workspace-discovery-unsupported-check/script @@ -1,5 +1,5 @@ -# uv older than 0.9.9 cannot report the workspace root, so the plan is the same -# as for a standalone project: no workspace fields. +# uv older than 0.9.9 cannot report the workspace root, so in a workspace member +# the plan is the same as for a standalone project: no workspace fields. mv fake-uv.sh uv chmod +x uv cleanup() { @@ -8,4 +8,4 @@ cleanup() { trap cleanup EXIT export PATH="$(pwd):$PATH" -trace $CLI environments setup-local --serverless-version 4 --dry-run --output json +withdir bundles/a trace $CLI environments setup-local --serverless-version 4 --dry-run --output json diff --git a/acceptance/localenv/workspace-member-check/script b/acceptance/localenv/workspace-member-check/script index 254939cf906..ec88e4f62e2 100644 --- a/acceptance/localenv/workspace-member-check/script +++ b/acceptance/localenv/workspace-member-check/script @@ -3,14 +3,11 @@ # answers `uv workspace dir` (see acceptance/cmd/psql for the pattern). mv fake-uv.sh uv chmod +x uv -# The script changes folder, so remove the fake uv by its absolute path. -fake_uv="$(pwd)/uv" cleanup() { - rm -f "$fake_uv" + rm -f uv } trap cleanup EXIT export PATH="$(pwd):$PATH" -cd bundles/a -trace $CLI environments setup-local --serverless-version 4 --dry-run -trace $CLI environments setup-local --serverless-version 4 --dry-run --output json +withdir bundles/a trace $CLI environments setup-local --serverless-version 4 --dry-run +withdir bundles/a trace $CLI environments setup-local --serverless-version 4 --dry-run --output json diff --git a/acceptance/localenv/workspace-member/script b/acceptance/localenv/workspace-member/script index febe7abf99a..7c321776da6 100644 --- a/acceptance/localenv/workspace-member/script +++ b/acceptance/localenv/workspace-member/script @@ -4,16 +4,12 @@ # PATH stands in for the network-dependent sync (see acceptance/cmd/psql). mv fake-uv.sh uv chmod +x uv -# The script changes folder, so remove the fake uv by its absolute path. -fake_uv="$(pwd)/uv" cleanup() { - rm -f "$fake_uv" + rm -f uv } trap cleanup EXIT export PATH="$(pwd):$PATH" -cd bundles/a -trace $CLI environments setup-local --serverless-version 4 --output json -cd ../.. +withdir bundles/a trace $CLI environments setup-local --serverless-version 4 --output json trace cat pyproject.toml trace cat bundles/a/pyproject.toml diff --git a/cmd/environments/output.go b/cmd/environments/output.go index eec6af2389f..3ce888bd6da 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -3,6 +3,7 @@ package environments import ( "context" "fmt" + "path/filepath" "runtime" "strings" @@ -152,7 +153,9 @@ func renderInstalledPythonFallback(ctx context.Context, res *libslocalenv.Result // that fails with "source is not recognized" and a path that does not exist. func activateHint(venvPath string) string { if runtime.GOOS == "windows" { - return venvPath + `\Scripts\activate` + // 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 "source " + venvPath + "/bin/activate" } diff --git a/cmd/environments/output_test.go b/cmd/environments/output_test.go index a7053fb9496..f569e849512 100644 --- a/cmd/environments/output_test.go +++ b/cmd/environments/output_test.go @@ -142,6 +142,16 @@ func TestRenderDryRun(t *testing.T) { assert.Contains(t, out, "No files were modified") } +func TestActivateHintWorkspaceMember(t *testing.T) { + // venvPath is in slash form; cmd.exe needs backslashes in a relative path. + hint := activateHint("../../.venv") + if runtime.GOOS == "windows" { + assert.Equal(t, `..\..\.venv\Scripts\activate`, hint) + } else { + assert.Equal(t, "source ../../.venv/bin/activate", hint) + } +} + 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 9b97925164c..7b53ec3fb47 100644 --- a/cmd/environments/sync.go +++ b/cmd/environments/sync.go @@ -24,7 +24,7 @@ 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.`, +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.`, Example: ` # Match a serverless version databricks environments setup-local --serverless-version 5 diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go index 8c8f69e872e..676050c0867 100644 --- a/libs/localenv/merge.go +++ b/libs/localenv/merge.go @@ -113,11 +113,11 @@ type MergeOptions struct { // databricks-connect dependency unmanaged: an existing pin is preserved and none // is injected or asserted. SkipDBConnect bool - // SkipToolUv leaves the [tool.uv] constraint block of this file unmanaged. It - // is set for a uv workspace member, whose constraint block is written to the - // workspace root instead (uv reads constraint-dependencies only from the root). - // requires-python is still managed in the member. - SkipToolUv bool + // ConstraintsAtWorkspaceRoot is set for a uv workspace member: its constraint + // block is written to the workspace root (uv reads constraint-dependencies only + // there), so this file gets none, and a managed block that an earlier run left + // here is removed. requires-python is still managed in the member. + ConstraintsAtWorkspaceRoot bool } // MergeManaged applies the managed transforms to target, preserving every other @@ -168,21 +168,17 @@ func MergeManaged(target []byte, c Constraints, opts MergeOptions) (merged []byt regions = append(regions, regionDatabricksEnvironment) } - if !opts.SkipConstraints && !opts.SkipToolUv { + if !opts.SkipConstraints { var uvChanged bool - lines, uvChanged = mergeToolUv(lines, c.ConstraintDeps) - if uvChanged { - regions = append(regions, regionToolUv) + if opts.ConstraintsAtWorkspaceRoot { + // A managed block left in the member by an earlier run has no effect + // (uv ignores it), so remove it rather than keep pins that disagree + // with what uv applies. + lines, uvChanged = removeManagedToolUv(lines) + } else { + lines, uvChanged = mergeToolUv(lines, c.ConstraintDeps) } - } - - // A workspace member's pins live at the root. A managed block left in the - // member by an earlier run has no effect (uv ignores it), so remove it - // rather than keep pins that disagree with what uv applies. - if !opts.SkipConstraints && opts.SkipToolUv { - var removed bool - lines, removed = removeManagedToolUv(lines) - if removed { + if uvChanged { regions = append(regions, regionToolUv) } } diff --git a/libs/localenv/merge_test.go b/libs/localenv/merge_test.go index 7ac16630d46..ac83e534b5c 100644 --- a/libs/localenv/merge_test.go +++ b/libs/localenv/merge_test.go @@ -1272,10 +1272,10 @@ func TestRenderFreshPyprojectSkipDBConnectOmitsDBConnect(t *testing.T) { requireValidTOML(t, out) } -func TestMergeManagedSkipToolUvLeavesBlockAlone(t *testing.T) { +func TestMergeManagedConstraintsAtWorkspaceRootLeavesBlockAlone(t *testing.T) { in := []byte("[project]\nname = \"a\"\nrequires-python = \">=3.10\"\n") c := Constraints{RequiresPython: ">=3.12", ConstraintDeps: []string{"numpy==1.26.4"}} - out, regions, err := MergeManaged(in, c, MergeOptions{SkipToolUv: true}) + out, regions, err := MergeManaged(in, c, MergeOptions{ConstraintsAtWorkspaceRoot: true}) require.NoError(t, err) assert.Contains(t, string(out), `requires-python = ">=3.12"`) assert.NotContains(t, string(out), "constraint-dependencies") @@ -1304,28 +1304,28 @@ func TestMergeToolUvBlockKeepsCRLF(t *testing.T) { assert.NotContains(t, strings.ReplaceAll(string(out), "\r\n", ""), "\n") } -func TestMergeManagedSkipToolUvRemovesStaleStandaloneBlock(t *testing.T) { +func TestMergeManagedConstraintsAtWorkspaceRootRemovesStaleStandaloneBlock(t *testing.T) { // A run before workspace support wrote the managed block into the member, // where uv ignores it. The member merge removes it; the root holds the pins. in := "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n" + managedMarkerStart + "\n[tool.uv]\nconstraint-dependencies = [\n \"x==1\",\n]\n" + managedMarkerEnd + "\n" c := Constraints{RequiresPython: ">=3.12"} - out, regions, err := MergeManaged([]byte(in), c, MergeOptions{SkipToolUv: true, SkipDBConnect: true}) + out, regions, err := MergeManaged([]byte(in), c, MergeOptions{ConstraintsAtWorkspaceRoot: true, SkipDBConnect: true}) require.NoError(t, err) assert.Equal(t, "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n", string(out)) assert.Contains(t, regions, regionToolUv) - again, regions2, err := MergeManaged(out, c, MergeOptions{SkipToolUv: true, SkipDBConnect: true}) + again, regions2, err := MergeManaged(out, c, MergeOptions{ConstraintsAtWorkspaceRoot: true, SkipDBConnect: true}) require.NoError(t, err) assert.Equal(t, out, again) assert.NotContains(t, regions2, regionToolUv) } -func TestMergeManagedSkipToolUvRemovesStaleBlockKeepsUserTable(t *testing.T) { +func TestMergeManagedConstraintsAtWorkspaceRootRemovesStaleBlockKeepsUserTable(t *testing.T) { in := "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n[tool.uv]\npackage = false\n" + managedMarkerStart + "\nconstraint-dependencies = [\n \"x==1\",\n]\n" + managedMarkerEnd + "\n" c := Constraints{RequiresPython: ">=3.12"} - out, _, err := MergeManaged([]byte(in), c, MergeOptions{SkipToolUv: true, SkipDBConnect: true}) + out, _, err := MergeManaged([]byte(in), c, MergeOptions{ConstraintsAtWorkspaceRoot: true, SkipDBConnect: true}) require.NoError(t, err) assert.Equal(t, "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n[tool.uv]\npackage = false\n", string(out)) } @@ -1338,13 +1338,13 @@ func TestMergeToolUvBlockRejectsDottedKeyWorkspace(t *testing.T) { assert.Error(t, err) } -func TestMergeManagedSkipToolUvKeepsHeaderForTrailingUserKeys(t *testing.T) { +func TestMergeManagedConstraintsAtWorkspaceRootKeepsHeaderForTrailingUserKeys(t *testing.T) { // The user added a key below an earlier standalone block. It belongs to that // block's [tool.uv] table, so the header must survive the block's removal. in := "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n" + managedMarkerStart + "\n[tool.uv]\nconstraint-dependencies = [\n \"x==1\",\n]\n" + managedMarkerEnd + "\npackage = false\n" c := Constraints{RequiresPython: ">=3.12"} - out, _, err := MergeManaged([]byte(in), c, MergeOptions{SkipToolUv: true, SkipDBConnect: true}) + out, _, err := MergeManaged([]byte(in), c, MergeOptions{ConstraintsAtWorkspaceRoot: true, SkipDBConnect: true}) require.NoError(t, err) assert.Equal(t, "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n[tool.uv]\npackage = false\n", string(out)) } diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 9e31f302305..422188a1366 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -250,9 +250,13 @@ func (p *Pipeline) run(ctx context.Context) error { // values, so c keeps the real artifact values throughout. --constraints-only maps // to --no-dbconnect: it stops managing the pin (not reported here, not asserted in // validate) but keeps the resolved value on c. + // A workspace member's constraint block goes to the workspace root (see + // planRootMerge). A greenfield project is never a member + // (discoverWorkspaceRoot), so RenderFreshPyproject needs no such option. opts := MergeOptions{ - SkipConstraints: p.SkipConstraints, - SkipDBConnect: p.Mode == ModeConstraintsOnly, + SkipConstraints: p.SkipConstraints, + SkipDBConnect: p.Mode == ModeConstraintsOnly, + ConstraintsAtWorkspaceRoot: p.workspaceRoot != "", } dbcVersion := dbcVersionFromPin(c.DatabricksConnect) @@ -443,12 +447,6 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, effective := *c effective.EnvironmentVersion = envVersion - // A workspace member's constraint block goes to the workspace root (see - // planRootMerge), so the member merge leaves its own [tool.uv] alone. A - // greenfield project is never a member (discoverWorkspaceRoot), so - // RenderFreshPyproject needs no such option. - opts.SkipToolUv = p.workspaceRoot != "" - var changedRegions []string if greenfield { // No existing pyproject.toml — render a fresh one. The project name comes @@ -544,7 +542,9 @@ func (p *Pipeline) planRootMerge(deps []string, opts MergeOptions) (*rootMerge, if err != nil { return nil, p.fail(PhaseMerge, false, NewError(ErrMerge, err, "merge workspace root constraints failed")) } - if changed && bytes.Contains(base, []byte(managedMarkerStart)) { + // Any earlier pins are replaced, whether an earlier run (maybe for another + // bundle) wrote them or the user did, and the new ones apply to every member. + if changed && bytes.Contains(base, []byte("constraint-dependencies")) { p.res.Warnings = append(p.res.Warnings, Warning{ Code: WarnWorkspaceConstraintsReplaced, Message: fmt.Sprintf("the constraint pins in %s are replaced; all members of this uv workspace share them", filepath.ToSlash(path)), @@ -583,14 +583,15 @@ func (p *Pipeline) applyMerge(_ context.Context, mergedBytes []byte, root *rootM return p.fail(PhaseMerge, mutated, perr) } if root != nil { - // The member is written first; if the root write fails, disk is already - // mutated, so the failure always reports diskMutated. - backup, _, perr := p.writePyproject(root.path, root.merged, false) - if backup != "" { - p.res.WorkspaceBackupPath = filepath.ToSlash(backup) + // The member is written first, so a root failure reports disk as mutated + // when either write changed it. + rootBackup, rootMutated, perr := p.writePyproject(root.path, root.merged, false) + if rootBackup != "" { + p.res.WorkspaceBackupPath = filepath.ToSlash(rootBackup) } if perr != nil { - return p.fail(PhaseMerge, true, perr) + perr.Msg = "workspace root: " + perr.Msg + return p.fail(PhaseMerge, mutated || rootMutated, perr) } } return nil diff --git a/libs/localenv/provision_integration_test.go b/libs/localenv/provision_integration_test.go index 346c8447163..c3e648bffc0 100644 --- a/libs/localenv/provision_integration_test.go +++ b/libs/localenv/provision_integration_test.go @@ -114,6 +114,9 @@ func TestProvisionCreatesAndValidatesVenv(t *testing.T) { // dependency). func TestProvisionWorkspaceMember(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() for _, m := range []string{"a", "b"} { dir := filepath.Join(root, "bundles", m) diff --git a/libs/localenv/uv_test.go b/libs/localenv/uv_test.go index 3add58cb4ac..26f6786b421 100644 --- a/libs/localenv/uv_test.go +++ b/libs/localenv/uv_test.go @@ -56,6 +56,34 @@ func TestUvArgs(t *testing.T) { "python", "find", "--system", "--no-python-downloads", "cpython@==3.12.*", }, m.pythonFindArgs("3.12")) assert.Equal(t, []string{"pip", "install", "pip", "--python", "/p/.venv/bin/python"}, m.pipSeedArgs("/p/.venv/bin/python")) + assert.Equal(t, []string{"workspace", "dir"}, m.workspaceDirArgs()) +} + +func TestWorkspaceRootRunsInProjectDirAndTrimsOutput(t *testing.T) { + project := filepath.Join("p", "proj") + ctx, stub := process.WithStub(t.Context()) + var dir string + stub.WithCallback(func(cmd *exec.Cmd) error { + dir = cmd.Dir + _, err := cmd.Stdout.Write([]byte("/repo\n")) + return err + }) + m := &uvManager{bin: "uv"} + + root, err := m.WorkspaceRoot(ctx, project) + require.NoError(t, err) + assert.Equal(t, "/repo", root) + assert.Equal(t, project, dir) + assert.Equal(t, []string{uvStubCommand("workspace dir")}, stub.Commands()) +} + +func TestWorkspaceRootReturnsUvError(t *testing.T) { + ctx, stub := process.WithStub(t.Context()) + stub.WithFailureFor(`workspace dir`, errors.New("exit status 2")) + m := &uvManager{bin: "uv"} + + _, err := m.WorkspaceRoot(ctx, "proj") + assert.Error(t, err) } func TestEnsurePythonStopsAfterSuccessfulInstall(t *testing.T) { diff --git a/libs/localenv/workspace_test.go b/libs/localenv/workspace_test.go index 893f83b945b..c3b097dfad1 100644 --- a/libs/localenv/workspace_test.go +++ b/libs/localenv/workspace_test.go @@ -104,14 +104,6 @@ func newMemberPipeline(t *testing.T) (p *Pipeline, root, member string) { return p, root, member } -func warningCodes(res *Result) []string { - var codes []string - for _, w := range res.Warnings { - codes = append(codes, w.Code) - } - return codes -} - func TestPipelineWorkspaceMember(t *testing.T) { p, root, member := newMemberPipeline(t) res, err := p.Run(t.Context()) @@ -131,7 +123,7 @@ func TestPipelineWorkspaceMember(t *testing.T) { assert.Equal(t, filepath.ToSlash(root), res.WorkspaceRoot) assert.Equal(t, filepath.ToSlash(filepath.Join(root, backupFile)), res.WorkspaceBackupPath) assert.Equal(t, filepath.ToSlash(filepath.Join(member, backupFile)), res.BackupPath) - assert.NotContains(t, warningCodes(res), WarnWorkspaceConstraintsReplaced, "first run must not warn") + assert.NotContains(t, codes(res.Warnings), WarnWorkspaceConstraintsReplaced, "first run must not warn") } func TestPipelineWorkspaceMemberIdempotent(t *testing.T) { @@ -142,7 +134,7 @@ func TestPipelineWorkspaceMemberIdempotent(t *testing.T) { require.NoError(t, err) assert.Empty(t, res.BackupPath) assert.Empty(t, res.WorkspaceBackupPath) - assert.NotContains(t, warningCodes(res), WarnWorkspaceConstraintsReplaced) + assert.NotContains(t, codes(res.Warnings), WarnWorkspaceConstraintsReplaced) for _, dir := range []string{root, member} { matches, _ := filepath.Glob(filepath.Join(dir, "pyproject.toml.*.bak")) assert.Empty(t, matches, "no timestamped backup in %s", dir) @@ -162,7 +154,7 @@ func TestPipelineWorkspaceConstraintsReplacedWarning(t *testing.T) { res, err := p.Run(t.Context()) require.NoError(t, err) - assert.Contains(t, warningCodes(res), WarnWorkspaceConstraintsReplaced) + assert.Contains(t, codes(res.Warnings), WarnWorkspaceConstraintsReplaced) } func TestPipelineWorkspaceMemberNoConstraints(t *testing.T) { @@ -285,3 +277,31 @@ func TestPipelineWorkspaceMemberSymlinkBelowRoot(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(rootBytes), "constraint-dependencies") } + +func TestPipelineWorkspaceReplacesUserRootPinsWithWarning(t *testing.T) { + p, root, _ := newMemberPipeline(t) + require.NoError(t, os.WriteFile(filepath.Join(root, pyprojectFile), + []byte("[tool.uv.workspace]\nmembers = [\"bundles/*\"]\n\n[tool.uv]\nconstraint-dependencies = [\"urllib3<2\"]\n"), 0o644)) + + res, err := p.Run(t.Context()) + require.NoError(t, err) + assert.Contains(t, codes(res.Warnings), WarnWorkspaceConstraintsReplaced) +} + +func TestApplyMergeRootFailureBeforeAnyWriteIsNotDiskMutated(t *testing.T) { + // The member merge is a no-op and the root file cannot be read, so nothing on + // disk changed and the failure must say so. + _, member := memberLayout(t) + memberPath := filepath.Join(member, pyprojectFile) + current, err := os.ReadFile(memberPath) + require.NoError(t, err) + p := &Pipeline{ProjectDir: member, res: NewResult()} + p.res.Phases = initialPhases() + missing := filepath.Join(t.TempDir(), pyprojectFile) + + err = p.applyMerge(t.Context(), current, &rootMerge{path: missing, merged: []byte("x")}, false) + require.Error(t, err) + require.NotNil(t, p.res.Error) + assert.False(t, p.res.Error.DiskMutated) + assert.Contains(t, p.res.Error.Error(), "workspace root") +} From e0aed5f59abfc5b301dc6d4b542ba5c3fed51e7c Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 14:39:57 +0200 Subject: [PATCH 12/17] localenv: harden uv workspace root handling after review - Count the venv path levels from the symlink-resolved project folder, so a symlink that changes the folder depth reports the venv uv uses. - Reject a root that defines [tool.uv] with quoted or inline keys too. - Warn about replaced root pins only when the file had constraint pins, not when a comment names the key. - Remove unmarked constraint pins that an older run left in a member. - Run uv workspace dir through runUvOutput; label the root region in the dry-run plan; correct the greenfield comments; extend the help text. Co-authored-by: Isaac --- acceptance/localenv/help/output.txt | 2 +- .../workspace-member-check/output.txt | 2 +- cmd/environments/sync.go | 2 +- libs/localenv/merge.go | 72 +++++++++++---- libs/localenv/merge_test.go | 88 ++++++++++++++----- libs/localenv/pipeline.go | 15 ++-- libs/localenv/uv.go | 5 +- libs/localenv/workspace.go | 39 ++++---- libs/localenv/workspace_test.go | 22 ++++- 9 files changed, 173 insertions(+), 74 deletions(-) diff --git a/acceptance/localenv/help/output.txt b/acceptance/localenv/help/output.txt index 7303039176b..98abac079af 100644 --- a/acceptance/localenv/help/output.txt +++ b/acceptance/localenv/help/output.txt @@ -2,7 +2,7 @@ Set up a local Python environment that matches a Databricks cluster or serverles 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. +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. Usage: databricks environments setup-local [flags] diff --git a/acceptance/localenv/workspace-member-check/output.txt b/acceptance/localenv/workspace-member-check/output.txt index 0dcf026ce60..3593a155e2a 100644 --- a/acceptance/localenv/workspace-member-check/output.txt +++ b/acceptance/localenv/workspace-member-check/output.txt @@ -6,7 +6,7 @@ Plan: [TEST_TMP_DIR]/pyproject.toml (workspace root) changed region: requires-python changed region: databricks-connect changed region: tool.databricks.environment - changed region: tool.uv.constraint-dependencies + changed region: tool.uv.constraint-dependencies (workspace root) Check complete. No files were modified. >>> [CLI] environments setup-local --serverless-version 4 --dry-run --output json diff --git a/cmd/environments/sync.go b/cmd/environments/sync.go index 7b53ec3fb47..b4e40474a03 100644 --- a/cmd/environments/sync.go +++ b/cmd/environments/sync.go @@ -24,7 +24,7 @@ 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.`, +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.`, Example: ` # Match a serverless version databricks environments setup-local --serverless-version 5 diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go index 676050c0867..66b5dfc90f1 100644 --- a/libs/localenv/merge.go +++ b/libs/localenv/merge.go @@ -31,6 +31,7 @@ const ( regionRequiresPython = "requires-python" regionDatabricksConnect = "databricks-connect" regionToolUv = "tool.uv.constraint-dependencies" + regionWorkspaceToolUv = regionToolUv + " (workspace root)" regionDatabricksEnvironment = "tool.databricks.environment" ) @@ -193,37 +194,56 @@ func MergeManaged(target []byte, c Constraints, opts MergeOptions) (merged []byt // MergeToolUvBlock reconciles only the managed [tool.uv] constraint-dependencies // block of target, preserving every other byte. It is used for the root // pyproject.toml of a uv workspace, which may be a virtual root with no [project] -// table. changed reports whether the block differs from the input. -func MergeToolUvBlock(target []byte, deps []string) (merged []byte, changed bool, err error) { +// table. changed reports whether the block differs from the input; replaced +// reports whether that change replaces constraint-dependencies the file already +// had (from an earlier run or from the user). +func MergeToolUvBlock(target []byte, deps []string) (merged []byte, changed, replaced bool, err error) { merged, err = transformLines(target, func(lines []string) ([]string, error) { - if definesToolUvWithDottedKeys(lines) { + if definesToolUvWithoutHeader(lines) { return nil, errDottedToolUv } + hadPins := hasConstraintDeps(lines) var out []string out, changed = mergeToolUv(lines, deps) + replaced = changed && hadPins return out, nil }) if err != nil { - return nil, false, err + return nil, false, false, err } - return merged, changed, nil + return merged, changed, replaced, nil } -// errDottedToolUv: the file defines the [tool.uv] table with dotted keys, which -// the line-based merge cannot extend. Appending a [tool.uv] header would define -// the table twice, which uv rejects for the whole workspace. -var errDottedToolUv = errors.New("pyproject.toml defines [tool.uv] with dotted keys (for example tool.uv.workspace.members); use a [tool.uv.workspace] table so the constraint block can be added") +// hasConstraintDeps reports whether lines hold a managed block or a +// constraint-dependencies key in the [tool.uv] table. +func hasConstraintDeps(lines []string) bool { + if _, _, found := markerBounds(lines); found { + return true + } + header, end, ok := tableBounds(lines, "[tool.uv]") + if !ok { + return false + } + return slices.ContainsFunc(lines[header+1:end], constraintDepsRe.MatchString) +} + +// errDottedToolUv is returned when the file defines the [tool.uv] table without +// a [tool.uv] header (dotted, quoted, or inline keys). The line-based merge +// cannot extend such a table, and appending a [tool.uv] header would define it +// twice, which uv rejects for the whole workspace. +var errDottedToolUv = errors.New("pyproject.toml defines [tool.uv] without a [tool.uv] header (for example tool.uv.workspace.members or an inline table); use [tool.uv] and [tool.uv.workspace] tables so the constraint block can be added") -// toolUvDottedTopRe and toolUvDottedToolRe match a dotted key that defines the [tool.uv] table: tool.uv.… -// at the top level, or uv.… inside [tool]. +// toolUvTopRe and toolUvInToolRe match a key that defines the [tool.uv] table +// without its header: tool.uv.… or an inline tool = {…} at the top level, and +// uv.… or uv = {…} inside [tool]. Keys may be quoted. var ( - toolUvDottedTopRe = regexp.MustCompile(`^\s*tool\s*\.\s*uv\s*[.=]`) - toolUvDottedToolRe = regexp.MustCompile(`^\s*uv\s*[.=]`) + toolUvTopRe = regexp.MustCompile(`^\s*["']?tool["']?\s*(\.\s*["']?uv["']?\s*[.=]|=\s*\{)`) + toolUvInToolRe = regexp.MustCompile(`^\s*["']?uv["']?\s*[.=]`) ) -// definesToolUvWithDottedKeys reports whether lines define the [tool.uv] table -// only through dotted keys, with no [tool.uv] header. -func definesToolUvWithDottedKeys(lines []string) bool { +// definesToolUvWithoutHeader reports whether lines define the [tool.uv] table +// only through keys, with no [tool.uv] header. +func definesToolUvWithoutHeader(lines []string) bool { if _, _, ok := tableBounds(lines, "[tool.uv]"); ok { return false } @@ -233,7 +253,7 @@ func definesToolUvWithDottedKeys(lines []string) bool { table = name continue } - if (table == "" && toolUvDottedTopRe.MatchString(line)) || (table == "[tool]" && toolUvDottedToolRe.MatchString(line)) { + if (table == "" && toolUvTopRe.MatchString(line)) || (table == "[tool]" && toolUvInToolRe.MatchString(line)) { return true } } @@ -1032,7 +1052,7 @@ func mergeToolUv(lines, deps []string) ([]string, bool) { func removeManagedToolUv(lines []string) ([]string, bool) { start, stop, found := markerBounds(lines) if !found { - return lines, false + return removeUnmarkedConstraintDeps(lines) } var keep []string if slices.ContainsFunc(lines[start:stop+1], func(l string) bool { return headerName(l) == "[tool.uv]" }) && @@ -1048,6 +1068,22 @@ func removeManagedToolUv(lines []string) ([]string, bool) { return out, true } +// removeUnmarkedConstraintDeps removes constraint-dependencies from a plain +// [tool.uv] table, the form a run before the managed markers wrote. Like +// mergeToolUv, it drops the whole table when that key is all it holds. +func removeUnmarkedConstraintDeps(lines []string) ([]string, bool) { + header, end, ok := tableBounds(lines, "[tool.uv]") + if !ok || !slices.ContainsFunc(lines[header+1:end], constraintDepsRe.MatchString) { + return lines, false + } + if toolUvHasOnlyConstraintDeps(lines, header, end) { + out := make([]string, 0, len(lines)-(end-header)) + out = append(out, lines[:header]...) + return append(out, lines[end:]...), true + } + return removeConstraintDeps(lines, header, end), true +} + // tableBodyHasKeys reports whether the lines from index from up to the next // table header hold anything other than blank lines and comments. func tableBodyHasKeys(lines []string, from int) bool { diff --git a/libs/localenv/merge_test.go b/libs/localenv/merge_test.go index ac83e534b5c..412a8759bd8 100644 --- a/libs/localenv/merge_test.go +++ b/libs/localenv/merge_test.go @@ -1272,7 +1272,7 @@ func TestRenderFreshPyprojectSkipDBConnectOmitsDBConnect(t *testing.T) { requireValidTOML(t, out) } -func TestMergeManagedConstraintsAtWorkspaceRootLeavesBlockAlone(t *testing.T) { +func TestMergeManagedConstraintsAtWorkspaceRootAddsNoBlock(t *testing.T) { in := []byte("[project]\nname = \"a\"\nrequires-python = \">=3.10\"\n") c := Constraints{RequiresPython: ">=3.12", ConstraintDeps: []string{"numpy==1.26.4"}} out, regions, err := MergeManaged(in, c, MergeOptions{ConstraintsAtWorkspaceRoot: true}) @@ -1284,14 +1284,14 @@ func TestMergeManagedConstraintsAtWorkspaceRootLeavesBlockAlone(t *testing.T) { func TestMergeToolUvBlockVirtualRoot(t *testing.T) { in := []byte("[tool.uv.workspace]\nmembers = [\"bundles/*\"]\n") - out, changed, err := MergeToolUvBlock(in, []string{"numpy==1.26.4"}) + out, changed, _, err := MergeToolUvBlock(in, []string{"numpy==1.26.4"}) require.NoError(t, err) assert.True(t, changed) assert.Contains(t, string(out), managedMarkerStart) assert.Contains(t, string(out), `"numpy==1.26.4"`) assert.Contains(t, string(out), "[tool.uv.workspace]") - again, changed2, err := MergeToolUvBlock(out, []string{"numpy==1.26.4"}) + again, changed2, _, err := MergeToolUvBlock(out, []string{"numpy==1.26.4"}) require.NoError(t, err) assert.False(t, changed2) assert.Equal(t, out, again) @@ -1299,7 +1299,7 @@ func TestMergeToolUvBlockVirtualRoot(t *testing.T) { func TestMergeToolUvBlockKeepsCRLF(t *testing.T) { in := []byte("[tool.uv.workspace]\r\nmembers = [\"a\"]\r\n") - out, _, err := MergeToolUvBlock(in, []string{"x==1"}) + out, _, _, err := MergeToolUvBlock(in, []string{"x==1"}) require.NoError(t, err) assert.NotContains(t, strings.ReplaceAll(string(out), "\r\n", ""), "\n") } @@ -1330,14 +1330,6 @@ func TestMergeManagedConstraintsAtWorkspaceRootRemovesStaleBlockKeepsUserTable(t assert.Equal(t, "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n[tool.uv]\npackage = false\n", string(out)) } -func TestMergeToolUvBlockRejectsDottedKeyWorkspace(t *testing.T) { - // A dotted key defines the [tool.uv] table, so appending a [tool.uv] header - // would define it twice: invalid TOML that breaks uv for the whole workspace. - in := []byte("tool.uv.workspace.members = [\"bundles/*\"]\n") - _, _, err := MergeToolUvBlock(in, []string{"x==1"}) - assert.Error(t, err) -} - func TestMergeManagedConstraintsAtWorkspaceRootKeepsHeaderForTrailingUserKeys(t *testing.T) { // The user added a key below an earlier standalone block. It belongs to that // block's [tool.uv] table, so the header must survive the block's removal. @@ -1349,16 +1341,68 @@ func TestMergeManagedConstraintsAtWorkspaceRootKeepsHeaderForTrailingUserKeys(t assert.Equal(t, "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n[tool.uv]\npackage = false\n", string(out)) } -func TestMergeToolUvBlockAllowsWorkspaceSubtable(t *testing.T) { - // A [tool.uv.workspace] header does not define [tool.uv] itself, so the - // managed [tool.uv] block can follow it (uv accepts this layout). - in := []byte("[tool.uv.workspace]\nmembers = [\"bundles/*\"]\n") - _, _, err := MergeToolUvBlock(in, []string{"x==1"}) - assert.NoError(t, err) +func TestMergeToolUvBlockDottedKeyGuard(t *testing.T) { + // A [tool.uv] table defined without its own header cannot be extended by the + // line-based merge; appending a [tool.uv] header would define it twice, which + // uv rejects for the whole workspace. + tests := []struct { + name string + in string + wantErr bool + }{ + {"workspace subtable header", "[tool.uv.workspace]\nmembers = [\"bundles/*\"]\n", false}, + {"dotted key at top level", "tool.uv.workspace.members = [\"bundles/*\"]\n", true}, + {"quoted dotted key", "\"tool\".\"uv\".workspace.members = [\"bundles/*\"]\n", true}, + {"dotted key in [tool]", "[tool]\nuv.workspace.members = [\"bundles/*\"]\n", true}, + {"inline tool table", "tool = { uv = { workspace = { members = [\"bundles/*\"] } } }\n", true}, + {"inline uv table in [tool]", "[tool]\nuv = { workspace = { members = [\"bundles/*\"] } }\n", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, _, _, err := MergeToolUvBlock([]byte(tc.in), []string{"x==1"}) + if tc.wantErr { + assert.ErrorIs(t, err, errDottedToolUv) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestMergeToolUvBlockReportsReplacedPins(t *testing.T) { + tests := []struct { + name string + in string + wantReplaced bool + }{ + {"no pins", "[tool.uv.workspace]\nmembers = [\"a\"]\n", false}, + {"only a comment names the key", "# constraint-dependencies are added by setup-local\n[tool.uv.workspace]\nmembers = [\"a\"]\n", false}, + {"user pins", "[tool.uv]\nconstraint-dependencies = [\"urllib3<2\"]\n", true}, + {"managed pins", managedMarkerStart + "\n[tool.uv]\nconstraint-dependencies = [\n \"old==1\",\n]\n" + managedMarkerEnd + "\n", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, _, replaced, err := MergeToolUvBlock([]byte(tc.in), []string{"x==1"}) + require.NoError(t, err) + assert.Equal(t, tc.wantReplaced, replaced) + }) + } } -func TestMergeToolUvBlockRejectsDottedKeyInToolTable(t *testing.T) { - in := []byte("[tool]\nuv.workspace.members = [\"bundles/*\"]\n") - _, _, err := MergeToolUvBlock(in, []string{"x==1"}) - assert.ErrorIs(t, err, errDottedToolUv) +func TestMergeManagedConstraintsAtWorkspaceRootRemovesUnmarkedPins(t *testing.T) { + // A run before the managed markers wrote a plain [tool.uv] table with only + // constraint-dependencies; the standalone merge treats it as its own, so the + // member merge removes it too. Other [tool.uv] keys stay. + c := Constraints{RequiresPython: ">=3.12"} + opts := MergeOptions{ConstraintsAtWorkspaceRoot: true, SkipDBConnect: true} + head := "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n" + + out, regions, err := MergeManaged([]byte(head+"\n[tool.uv]\nconstraint-dependencies = [\"x==1\"]\n"), c, opts) + require.NoError(t, err) + assert.Equal(t, head, string(out)) + assert.Contains(t, regions, regionToolUv) + + out, _, err = MergeManaged([]byte(head+"\n[tool.uv]\npackage = false\nconstraint-dependencies = [\"x==1\"]\n"), c, opts) + require.NoError(t, err) + assert.Equal(t, head+"\n[tool.uv]\npackage = false\n", string(out)) } diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 422188a1366..1e7a78c561f 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -7,7 +7,6 @@ import ( "fmt" "os" "path/filepath" - "slices" "strings" "time" @@ -251,8 +250,8 @@ func (p *Pipeline) run(ctx context.Context) error { // to --no-dbconnect: it stops managing the pin (not reported here, not asserted in // validate) but keeps the resolved value on c. // A workspace member's constraint block goes to the workspace root (see - // planRootMerge). A greenfield project is never a member - // (discoverWorkspaceRoot), so RenderFreshPyproject needs no such option. + // planRootMerge). Discovery never reports a greenfield project as a member + // (see discoverWorkspaceRoot), so RenderFreshPyproject needs no such option. opts := MergeOptions{ SkipConstraints: p.SkipConstraints, SkipDBConnect: p.Mode == ModeConstraintsOnly, @@ -482,8 +481,8 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, if err != nil { return nil, nil, greenfield, err } - if root != nil && !bytes.Equal(root.base, root.merged) && !slices.Contains(changedRegions, regionToolUv) { - changedRegions = append(changedRegions, regionToolUv) + if root != nil && !bytes.Equal(root.base, root.merged) { + changedRegions = append(changedRegions, regionWorkspaceToolUv) } // Under --dry-run, build the plan (with a diff) for reporting. A real run does @@ -538,13 +537,13 @@ func (p *Pipeline) planRootMerge(deps []string, opts MergeOptions) (*rootMerge, if err != nil { return nil, p.fail(PhaseMerge, false, NewError(ErrMerge, err, "read workspace root pyproject.toml %s failed", filepath.ToSlash(path))) } - merged, changed, err := MergeToolUvBlock(base, deps) + merged, _, replaced, err := MergeToolUvBlock(base, deps) if err != nil { return nil, p.fail(PhaseMerge, false, NewError(ErrMerge, err, "merge workspace root constraints failed")) } - // Any earlier pins are replaced, whether an earlier run (maybe for another + // Earlier pins are replaced, whether an earlier run (maybe for another // bundle) wrote them or the user did, and the new ones apply to every member. - if changed && bytes.Contains(base, []byte("constraint-dependencies")) { + if replaced { p.res.Warnings = append(p.res.Warnings, Warning{ Code: WarnWorkspaceConstraintsReplaced, Message: fmt.Sprintf("the constraint pins in %s are replaced; all members of this uv workspace share them", filepath.ToSlash(path)), diff --git a/libs/localenv/uv.go b/libs/localenv/uv.go index 0318ee992c3..40ef1deff3e 100644 --- a/libs/localenv/uv.go +++ b/libs/localenv/uv.go @@ -313,10 +313,7 @@ func (m *uvManager) WorkspaceRoot(ctx context.Context, projectDir string) (strin } bin = found } - out, err := process.Background(ctx, append([]string{bin}, m.workspaceDirArgs()...), - process.WithDir(projectDir), - process.WithProcessGroup(), - ) + out, err := m.runUvOutput(ctx, append([]string{bin}, m.workspaceDirArgs()...), projectDir) if err != nil { return "", err } diff --git a/libs/localenv/workspace.go b/libs/localenv/workspace.go index a33f65ad991..59ae59ac35c 100644 --- a/libs/localenv/workspace.go +++ b/libs/localenv/workspace.go @@ -13,9 +13,10 @@ import ( // It returns "" for both when ProjectDir is not a member: a standalone project, // the root itself, or a project uv cannot place. // -// A folder with no pyproject.toml yet (greenfield) is not treated as a member, -// even when a members glob of a parent workspace matches it: the first run sets -// it up as a standalone project, and a re-run finds it as a member. +// A folder with no pyproject.toml yet (greenfield) is not treated as a member. +// When a members glob of a parent workspace matches it, the file this command +// creates makes it a member: the first run then fails at provision (uv syncs +// the root venv), and a re-run finds the member. // // A discovery failure (uv older than 0.9.9, uv not found under --dry-run, a // malformed workspace) keeps the standalone behavior. A malformed workspace @@ -31,27 +32,29 @@ func (p *Pipeline) discoverWorkspaceRoot(ctx context.Context) (root, rel string) } // uv prints the root with symlinks resolved and in the on-disk letter case, // while ProjectDir keeps the form the shell gave (on macOS, /var vs - // /private/var, and any letter case). So compare folders by identity. Prefer - // the root in ProjectDir's form, so the paths the command reports match the - // folder the user ran it from. If a symlink sits between the root and - // ProjectDir, only the symlink-resolved ProjectDir reaches the root; then use - // uv's root, with the relative path the OS resolves. - if ancestor, levels, ok := sameFolderAncestor(p.ProjectDir, root); ok { - if levels == 0 { - return "", "" - } - return ancestor, upLevels(levels) - } + // /private/var, and any letter case). So compare folders by identity. + // + // The relative path is counted from the symlink-resolved ProjectDir, because + // that is where the OS resolves "..". The root is reported in ProjectDir's + // form when one of its parents is the root, so the paths the command reports + // match the folder the user ran it from; otherwise uv's form is used. resolved, err := filepath.EvalSymlinks(p.ProjectDir) if err != nil { log.Debugf(ctx, CommandName+": cannot resolve the project folder: %v", err) return "", "" } - if _, levels, ok := sameFolderAncestor(resolved, root); ok && levels > 0 { - return root, upLevels(levels) + _, levels, ok := sameFolderAncestor(resolved, root) + if !ok { + log.Debugf(ctx, CommandName+": workspace root %s is not a parent of the project folder", filepath.ToSlash(root)) + return "", "" + } + if levels == 0 { + return "", "" + } + if ancestor, _, ok := sameFolderAncestor(p.ProjectDir, root); ok { + return ancestor, upLevels(levels) } - log.Debugf(ctx, CommandName+": workspace root %s is not a parent of the project folder", filepath.ToSlash(root)) - return "", "" + return root, upLevels(levels) } // sameFolderAncestor returns the first of dir and its parents that is the same diff --git a/libs/localenv/workspace_test.go b/libs/localenv/workspace_test.go index c3b097dfad1..b1f005de1be 100644 --- a/libs/localenv/workspace_test.go +++ b/libs/localenv/workspace_test.go @@ -136,7 +136,8 @@ func TestPipelineWorkspaceMemberIdempotent(t *testing.T) { assert.Empty(t, res.WorkspaceBackupPath) assert.NotContains(t, codes(res.Warnings), WarnWorkspaceConstraintsReplaced) for _, dir := range []string{root, member} { - matches, _ := filepath.Glob(filepath.Join(dir, "pyproject.toml.*.bak")) + matches, err := filepath.Glob(filepath.Join(dir, "pyproject.toml.*.bak")) + require.NoError(t, err) assert.Empty(t, matches, "no timestamped backup in %s", dir) } } @@ -305,3 +306,22 @@ func TestApplyMergeRootFailureBeforeAnyWriteIsNotDiskMutated(t *testing.T) { assert.False(t, p.res.Error.DiskMutated) assert.Contains(t, p.res.Error.Error(), "workspace root") } + +func TestDiscoverWorkspaceRootSymlinkChangesDepth(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need extra privileges on Windows") + } + // /link -> /deep/bundles: the logical path reaches the root in + // two levels, but the OS resolves a relative path from the real member folder + // (/deep/bundles/a), three levels below the root. + root := t.TempDir() + real := filepath.Join(root, "deep", "bundles", "a") + require.NoError(t, os.MkdirAll(real, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(real, pyprojectFile), []byte("[project]\nname = \"a\"\n"), 0o644)) + require.NoError(t, os.Symlink(filepath.Join(root, "deep", "bundles"), filepath.Join(root, "link"))) + + p := &Pipeline{ProjectDir: filepath.Join(root, "link", "a"), PM: fakePM{root: root}} + gotRoot, gotRel := p.discoverWorkspaceRoot(t.Context()) + assert.Equal(t, root, gotRoot) + assert.Equal(t, filepath.Join("..", "..", ".."), gotRel) +} From af7d70d2b3d9edd39acb033138081d5201ee4c1a Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 14:56:35 +0200 Subject: [PATCH 13/17] localenv: validate the merged workspace root before writing it The line-based merge matches only the plain [tool.uv] spelling, so a root that spells it differently could get a table or key defined twice. The merged root must now parse as TOML. An empty managed block no longer counts as pins to replace, and the member acceptance test re-runs in text mode. Co-authored-by: Isaac --- .../localenv/workspace-member/output.txt | 15 +++++++++++++++ acceptance/localenv/workspace-member/script | 2 ++ libs/localenv/merge.go | 19 +++++++++++++++---- libs/localenv/merge_test.go | 8 +++++++- libs/localenv/pipeline.go | 15 ++++++++------- libs/localenv/result.go | 6 +++--- 6 files changed, 50 insertions(+), 15 deletions(-) diff --git a/acceptance/localenv/workspace-member/output.txt b/acceptance/localenv/workspace-member/output.txt index bc2a2ddc78f..549c02630f1 100644 --- a/acceptance/localenv/workspace-member/output.txt +++ b/acceptance/localenv/workspace-member/output.txt @@ -58,6 +58,21 @@ "durationMs": [DURATION_MS] } +>>> [CLI] environments setup-local --serverless-version 4 +✔ Local environment ready + + Compute target serverless 4 + Python 3.12 + databricks-connect 17.2.0 + Virtual env ../../.venv + pyproject.toml updated + Workspace root [TEST_TMP_DIR] + Root pyproject.toml unchanged + +Next steps: + • Activate it: source ../../.venv/bin/activate + • Or select ../../.venv as the Python interpreter in VS Code / Cursor + >>> cat pyproject.toml [project] name = "root" diff --git a/acceptance/localenv/workspace-member/script b/acceptance/localenv/workspace-member/script index 7c321776da6..0e2f3b7218f 100644 --- a/acceptance/localenv/workspace-member/script +++ b/acceptance/localenv/workspace-member/script @@ -11,5 +11,7 @@ trap cleanup EXIT export PATH="$(pwd):$PATH" withdir bundles/a trace $CLI environments setup-local --serverless-version 4 --output json +# A re-run changes nothing, and the text summary says so for the root. +withdir bundles/a trace $CLI environments setup-local --serverless-version 4 trace cat pyproject.toml trace cat bundles/a/pyproject.toml diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go index 66b5dfc90f1..92eb895185c 100644 --- a/libs/localenv/merge.go +++ b/libs/localenv/merge.go @@ -7,6 +7,8 @@ import ( "regexp" "slices" "strings" + + "github.com/BurntSushi/toml" ) // errMultilineString and errNoProjectTable are the conditions under which @@ -211,14 +213,23 @@ func MergeToolUvBlock(target []byte, deps []string) (merged []byte, changed, rep if err != nil { return nil, false, false, err } + // The line-based merge matches only the plain [tool.uv] spelling. Other valid + // spellings ([ tool.uv ], [tool."uv"], a quoted key) can make it define a table + // or key twice. This file is shared by every workspace member, so never return + // a merge that no longer parses. + if _, err := toml.Decode(string(merged), &map[string]any{}); err != nil { + return nil, false, false, fmt.Errorf("merged workspace root pyproject.toml is not valid TOML; write [tool.uv] and constraint-dependencies without quotes or spaces: %w", err) + } return merged, changed, replaced, nil } -// hasConstraintDeps reports whether lines hold a managed block or a -// constraint-dependencies key in the [tool.uv] table. +// hasConstraintDeps reports whether lines hold pins: a managed block with at +// least one entry, or a constraint-dependencies key in the [tool.uv] table. func hasConstraintDeps(lines []string) bool { - if _, _, found := markerBounds(lines); found { - return true + if start, stop, found := markerBounds(lines); found { + return slices.ContainsFunc(lines[start+1:stop], func(l string) bool { + return strings.HasPrefix(strings.TrimSpace(l), `"`) + }) } header, end, ok := tableBounds(lines, "[tool.uv]") if !ok { diff --git a/libs/localenv/merge_test.go b/libs/localenv/merge_test.go index 412a8759bd8..58ed0cac5a3 100644 --- a/libs/localenv/merge_test.go +++ b/libs/localenv/merge_test.go @@ -1356,12 +1356,17 @@ func TestMergeToolUvBlockDottedKeyGuard(t *testing.T) { {"dotted key in [tool]", "[tool]\nuv.workspace.members = [\"bundles/*\"]\n", true}, {"inline tool table", "tool = { uv = { workspace = { members = [\"bundles/*\"] } } }\n", true}, {"inline uv table in [tool]", "[tool]\nuv = { workspace = { members = [\"bundles/*\"] } }\n", true}, + // Header spellings the line-based merge does not match; the merged file + // would define [tool.uv] twice. + {"header with spaces", "[ tool.uv ]\ndev-dependencies = []\n", true}, + {"quoted header", "[tool.\"uv\"]\ndev-dependencies = []\n", true}, + {"quoted constraint key", "[tool.uv]\n\"constraint-dependencies\" = [\"y==1\"]\n", true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, _, _, err := MergeToolUvBlock([]byte(tc.in), []string{"x==1"}) if tc.wantErr { - assert.ErrorIs(t, err, errDottedToolUv) + assert.Error(t, err) } else { assert.NoError(t, err) } @@ -1379,6 +1384,7 @@ func TestMergeToolUvBlockReportsReplacedPins(t *testing.T) { {"only a comment names the key", "# constraint-dependencies are added by setup-local\n[tool.uv.workspace]\nmembers = [\"a\"]\n", false}, {"user pins", "[tool.uv]\nconstraint-dependencies = [\"urllib3<2\"]\n", true}, {"managed pins", managedMarkerStart + "\n[tool.uv]\nconstraint-dependencies = [\n \"old==1\",\n]\n" + managedMarkerEnd + "\n", true}, + {"empty managed block", managedMarkerStart + "\n[tool.uv]\nconstraint-dependencies = [\n]\n" + managedMarkerEnd + "\n", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 1e7a78c561f..a366b1e8600 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -481,7 +481,7 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, if err != nil { return nil, nil, greenfield, err } - if root != nil && !bytes.Equal(root.base, root.merged) { + if root != nil && root.changed { changedRegions = append(changedRegions, regionWorkspaceToolUv) } @@ -507,7 +507,7 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, } if root != nil { plan.WouldWriteWorkspace = filepath.ToSlash(root.path) - if !bytes.Equal(root.base, root.merged) { + if root.changed { if backupName, statErr := p.plannedBackupName(p.workspaceRoot); statErr == nil { plan.WouldBackupWorkspace = filepath.ToSlash(backupName) } @@ -520,9 +520,10 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, // rootMerge is the planned change to the workspace root pyproject.toml. type rootMerge struct { - path string - base []byte - merged []byte + path string + base []byte + merged []byte + changed bool } // planRootMerge merges the constraint block into the workspace root @@ -537,7 +538,7 @@ func (p *Pipeline) planRootMerge(deps []string, opts MergeOptions) (*rootMerge, if err != nil { return nil, p.fail(PhaseMerge, false, NewError(ErrMerge, err, "read workspace root pyproject.toml %s failed", filepath.ToSlash(path))) } - merged, _, replaced, err := MergeToolUvBlock(base, deps) + merged, changed, replaced, err := MergeToolUvBlock(base, deps) if err != nil { return nil, p.fail(PhaseMerge, false, NewError(ErrMerge, err, "merge workspace root constraints failed")) } @@ -549,7 +550,7 @@ func (p *Pipeline) planRootMerge(deps []string, opts MergeOptions) (*rootMerge, Message: fmt.Sprintf("the constraint pins in %s are replaced; all members of this uv workspace share them", filepath.ToSlash(path)), }) } - return &rootMerge{path: path, base: base, merged: merged}, nil + return &rootMerge{path: path, base: base, merged: merged, changed: changed}, nil } // pyprojectDiff renders the unified diff the --dry-run plan reports for one diff --git a/libs/localenv/result.go b/libs/localenv/result.go index af25ee424e8..75b2e8bdc90 100644 --- a/libs/localenv/result.go +++ b/libs/localenv/result.go @@ -298,9 +298,9 @@ const ( // validate hard-fail, which keys on the installed venv rather than the mode. WarnStandalonePysparkConflict = "W_STANDALONE_PYSPARK_CONFLICT" // WarnWorkspaceConstraintsReplaced: the project is a uv workspace member and - // the workspace root already had a managed constraint block with different - // pins (for example from another bundle's run). The new pins replace them, and - // every member of the workspace now resolves against them. + // the run replaced constraint pins the workspace root already had, from an + // earlier run (for example for another bundle) or written by the user. Every + // member of the workspace now resolves against the new pins. WarnWorkspaceConstraintsReplaced = "W_WORKSPACE_CONSTRAINTS_REPLACED" ) From 1f1b5dbb4ec3507af5635102214b341bc3b2c7a6 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 15:08:47 +0200 Subject: [PATCH 14/17] localenv: plan a root write only when the root changes; keep user blank lines The dry-run plan now names the workspace root file only when the run would change it. Removing a header-less managed block from a member keeps the user's blank line before it, and an empty constraint-dependencies list no longer counts as pins to replace. Co-authored-by: Isaac --- libs/localenv/merge.go | 24 +++++++++++++++++------- libs/localenv/merge_test.go | 12 ++++++++++++ libs/localenv/pipeline.go | 10 +++++----- libs/localenv/workspace_test.go | 14 ++++++++++++++ 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go index 92eb895185c..d2671ced95d 100644 --- a/libs/localenv/merge.go +++ b/libs/localenv/merge.go @@ -235,7 +235,15 @@ func hasConstraintDeps(lines []string) bool { if !ok { return false } - return slices.ContainsFunc(lines[header+1:end], constraintDepsRe.MatchString) + for i := header + 1; i < end; i++ { + if !constraintDepsRe.MatchString(lines[i]) { + continue + } + last, _ := constraintDepsArrayEnd(lines, i, end) + _, value, _ := strings.Cut(strings.Join(lines[i:last+1], "\n"), "=") + return strings.ContainsAny(value, `"'`) + } + return false } // errDottedToolUv is returned when the file defines the [tool.uv] table without @@ -1057,7 +1065,8 @@ func mergeToolUv(lines, deps []string) ([]string, bool) { // removeManagedToolUv removes the marker-bracketed managed [tool.uv] block, and // the one blank line appendManagedBlock puts before a standalone block. A -// header-less block inside a user [tool.uv] table leaves the table's own keys. +// header-less block inside a user [tool.uv] table leaves the table's own keys +// and blank lines. // A standalone block keeps its [tool.uv] header when user keys follow its end // marker, because those keys belong to that table. func removeManagedToolUv(lines []string) ([]string, bool) { @@ -1066,11 +1075,12 @@ func removeManagedToolUv(lines []string) ([]string, bool) { return removeUnmarkedConstraintDeps(lines) } var keep []string - if slices.ContainsFunc(lines[start:stop+1], func(l string) bool { return headerName(l) == "[tool.uv]" }) && - tableBodyHasKeys(lines, stop+1) { - keep = []string{"[tool.uv]"} - } else if start > 0 && strings.TrimSpace(lines[start-1]) == "" { - start-- + if slices.ContainsFunc(lines[start:stop+1], func(l string) bool { return headerName(l) == "[tool.uv]" }) { + if tableBodyHasKeys(lines, stop+1) { + keep = []string{"[tool.uv]"} + } else if start > 0 && strings.TrimSpace(lines[start-1]) == "" { + start-- + } } out := make([]string, 0, len(lines)-(stop-start+1)+len(keep)) out = append(out, lines[:start]...) diff --git a/libs/localenv/merge_test.go b/libs/localenv/merge_test.go index 58ed0cac5a3..97b299b5bba 100644 --- a/libs/localenv/merge_test.go +++ b/libs/localenv/merge_test.go @@ -1384,6 +1384,7 @@ func TestMergeToolUvBlockReportsReplacedPins(t *testing.T) { {"only a comment names the key", "# constraint-dependencies are added by setup-local\n[tool.uv.workspace]\nmembers = [\"a\"]\n", false}, {"user pins", "[tool.uv]\nconstraint-dependencies = [\"urllib3<2\"]\n", true}, {"managed pins", managedMarkerStart + "\n[tool.uv]\nconstraint-dependencies = [\n \"old==1\",\n]\n" + managedMarkerEnd + "\n", true}, + {"empty unmarked key", "[tool.uv]\nconstraint-dependencies = []\n", false}, {"empty managed block", managedMarkerStart + "\n[tool.uv]\nconstraint-dependencies = [\n]\n" + managedMarkerEnd + "\n", false}, } for _, tc := range tests { @@ -1412,3 +1413,14 @@ func TestMergeManagedConstraintsAtWorkspaceRootRemovesUnmarkedPins(t *testing.T) require.NoError(t, err) assert.Equal(t, head+"\n[tool.uv]\npackage = false\n", string(out)) } + +func TestMergeManagedConstraintsAtWorkspaceRootKeepsUserBlankLine(t *testing.T) { + // A header-less managed block sits inside the user's [tool.uv] table; the + // blank line before it is the user's, not the merge's. + in := "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n[tool.uv]\npackage = false\n\n" + + managedMarkerStart + "\nconstraint-dependencies = [\n \"x==1\",\n]\n" + managedMarkerEnd + "\n" + c := Constraints{RequiresPython: ">=3.12"} + out, _, err := MergeManaged([]byte(in), c, MergeOptions{ConstraintsAtWorkspaceRoot: true, SkipDBConnect: true}) + require.NoError(t, err) + assert.Equal(t, "[project]\nname = \"a\"\nrequires-python = \">=3.12\"\n\n[tool.uv]\npackage = false\n\n", string(out)) +} diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index a366b1e8600..37362caf3b2 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -505,12 +505,12 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, plan.WouldBackup = filepath.ToSlash(backupName) } } - if root != nil { + // Unlike the member file, the root is reported only when the run changes + // it; Result.WorkspaceRoot already names the root. + if root != nil && root.changed { plan.WouldWriteWorkspace = filepath.ToSlash(root.path) - if root.changed { - if backupName, statErr := p.plannedBackupName(p.workspaceRoot); statErr == nil { - plan.WouldBackupWorkspace = filepath.ToSlash(backupName) - } + if backupName, statErr := p.plannedBackupName(p.workspaceRoot); statErr == nil { + plan.WouldBackupWorkspace = filepath.ToSlash(backupName) } } p.res.Plan = plan diff --git a/libs/localenv/workspace_test.go b/libs/localenv/workspace_test.go index b1f005de1be..d52d6488200 100644 --- a/libs/localenv/workspace_test.go +++ b/libs/localenv/workspace_test.go @@ -325,3 +325,17 @@ func TestDiscoverWorkspaceRootSymlinkChangesDepth(t *testing.T) { assert.Equal(t, root, gotRoot) assert.Equal(t, filepath.Join("..", "..", ".."), gotRel) } + +func TestPipelineWorkspaceMemberDryRunReRunPlansNoRootWrite(t *testing.T) { + p, _, _ := newMemberPipeline(t) + _, err := p.Run(t.Context()) + require.NoError(t, err) + + p.Check = true + res, err := p.Run(t.Context()) + require.NoError(t, err) + require.NotNil(t, res.Plan) + assert.Empty(t, res.Plan.WouldWriteWorkspace) + assert.Empty(t, res.Plan.WouldBackupWorkspace) + assert.NotEmpty(t, res.WorkspaceRoot) +} From 163f8b615fafbf78a090ad78f851f2747afc453b Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 17:40:16 +0200 Subject: [PATCH 15/17] localenv: prefix the uv workspace JSON fields with uv In this CLI, "workspace" usually means a Databricks workspace. The new fields are uvWorkspaceRoot, uvWorkspaceBackupPath, plan.wouldWriteUvWorkspace, and plan.wouldBackupUvWorkspace, and the text output says "uv workspace root". Co-authored-by: Isaac --- .../localenv/workspace-member-check/output.txt | 12 ++++++------ acceptance/localenv/workspace-member/output.txt | 6 +++--- cmd/environments/output.go | 4 ++-- cmd/environments/output_test.go | 6 +++--- libs/localenv/merge.go | 2 +- libs/localenv/pipeline.go | 6 +++--- libs/localenv/result.go | 8 ++++---- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/acceptance/localenv/workspace-member-check/output.txt b/acceptance/localenv/workspace-member-check/output.txt index 3593a155e2a..b9fa432530e 100644 --- a/acceptance/localenv/workspace-member-check/output.txt +++ b/acceptance/localenv/workspace-member-check/output.txt @@ -2,11 +2,11 @@ >>> [CLI] environments setup-local --serverless-version 4 --dry-run warning: requires-python ">=3.10" is replaced by the environment's ">=3.12" Plan: [TEST_TMP_DIR]/bundles/a/pyproject.toml -Plan: [TEST_TMP_DIR]/pyproject.toml (workspace root) +Plan: [TEST_TMP_DIR]/pyproject.toml (uv workspace root) changed region: requires-python changed region: databricks-connect changed region: tool.databricks.environment - changed region: tool.uv.constraint-dependencies (workspace root) + changed region: tool.uv.constraint-dependencies (uv workspace root) Check complete. No files were modified. >>> [CLI] environments setup-local --serverless-version 4 --dry-run --output json @@ -30,12 +30,12 @@ Check complete. No files were modified. "plan": { "wouldWrite": "[TEST_TMP_DIR]/bundles/a/pyproject.toml", "wouldBackup": "[TEST_TMP_DIR]/bundles/a/pyproject.toml.bak", - "wouldWriteWorkspace": "[TEST_TMP_DIR]/pyproject.toml", - "wouldBackupWorkspace": "[TEST_TMP_DIR]/pyproject.toml.bak", + "wouldWriteUvWorkspace": "[TEST_TMP_DIR]/pyproject.toml", + "wouldBackupUvWorkspace": "[TEST_TMP_DIR]/pyproject.toml.bak", "wouldInstallPython": "3.12", - "diff": "--- pyproject.toml\n+++ pyproject.toml.new\n@@ -1,4 +1,12 @@\n [project]\n name = \"a\"\n version = \"0.1.0\"\n-requires-python = \"\u003e=3.10\"\n+requires-python = \"\u003e=3.12\"\n+\n+[dependency-groups]\n+dev = [\n+ \"databricks-connect~=17.2.0\",\n+]\n+\n+[tool.databricks.environment]\n+environment_version = \"4\"\n--- workspace root pyproject.toml\n+++ workspace root pyproject.toml.new\n@@ -5,3 +5,11 @@\n \n [tool.uv.workspace]\n members = [\"bundles/*\"]\n+\n+# managed by databricks environments setup-local — do not edit\n+[tool.uv]\n+constraint-dependencies = [\n+ \"pyarrow\u003c19\",\n+ \"pandas\u003c3\",\n+]\n+# end managed by databricks environments setup-local\n" + "diff": "--- pyproject.toml\n+++ pyproject.toml.new\n@@ -1,4 +1,12 @@\n [project]\n name = \"a\"\n version = \"0.1.0\"\n-requires-python = \"\u003e=3.10\"\n+requires-python = \"\u003e=3.12\"\n+\n+[dependency-groups]\n+dev = [\n+ \"databricks-connect~=17.2.0\",\n+]\n+\n+[tool.databricks.environment]\n+environment_version = \"4\"\n--- uv workspace root pyproject.toml\n+++ uv workspace root pyproject.toml.new\n@@ -5,3 +5,11 @@\n \n [tool.uv.workspace]\n members = [\"bundles/*\"]\n+\n+# managed by databricks environments setup-local — do not edit\n+[tool.uv]\n+constraint-dependencies = [\n+ \"pyarrow\u003c19\",\n+ \"pandas\u003c3\",\n+]\n+# end managed by databricks environments setup-local\n" }, - "workspaceRoot": "[TEST_TMP_DIR]", + "uvWorkspaceRoot": "[TEST_TMP_DIR]", "phases": [ { "phase": "preflight", diff --git a/acceptance/localenv/workspace-member/output.txt b/acceptance/localenv/workspace-member/output.txt index 549c02630f1..5a05c6f4c08 100644 --- a/acceptance/localenv/workspace-member/output.txt +++ b/acceptance/localenv/workspace-member/output.txt @@ -18,7 +18,7 @@ }, "greenfield": false, "venvPath": "../../.venv", - "workspaceRoot": "[TEST_TMP_DIR]", + "uvWorkspaceRoot": "[TEST_TMP_DIR]", "pythonResolution": "uv_install_succeeded", "phases": [ { @@ -54,7 +54,7 @@ ], "error": null, "backupPath": "[TEST_TMP_DIR]/bundles/a/pyproject.toml.bak", - "workspaceBackupPath": "[TEST_TMP_DIR]/pyproject.toml.bak", + "uvWorkspaceBackupPath": "[TEST_TMP_DIR]/pyproject.toml.bak", "durationMs": [DURATION_MS] } @@ -66,7 +66,7 @@ databricks-connect 17.2.0 Virtual env ../../.venv pyproject.toml updated - Workspace root [TEST_TMP_DIR] + uv workspace root [TEST_TMP_DIR] Root pyproject.toml unchanged Next steps: diff --git a/cmd/environments/output.go b/cmd/environments/output.go index 3ce888bd6da..a5bdf3c05b5 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -77,7 +77,7 @@ func renderResult(ctx context.Context, cmd *cobra.Command, res *libslocalenv.Res if res.Plan != nil { cmdio.LogString(ctx, "Plan: "+res.Plan.WouldWrite) if res.Plan.WouldWriteWorkspace != "" { - cmdio.LogString(ctx, "Plan: "+res.Plan.WouldWriteWorkspace+" (workspace root)") + cmdio.LogString(ctx, "Plan: "+res.Plan.WouldWriteWorkspace+" (uv workspace root)") } for _, region := range res.Plan.ChangedRegions { cmdio.LogString(ctx, " changed region: "+region) @@ -125,7 +125,7 @@ func renderSuccess(ctx context.Context, res *libslocalenv.Result) { if res.WorkspaceBackupPath != "" { rootDetail = "constraints updated (backup: " + res.WorkspaceBackupPath + ")" } - cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "Workspace root", res.WorkspaceRoot)) + cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "uv workspace root", res.WorkspaceRoot)) cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "Root pyproject.toml", rootDetail)) } renderInstalledPythonFallback(ctx, res) diff --git a/cmd/environments/output_test.go b/cmd/environments/output_test.go index f569e849512..cad5c5f4236 100644 --- a/cmd/environments/output_test.go +++ b/cmd/environments/output_test.go @@ -175,7 +175,7 @@ func TestRenderSuccessWorkspaceMember(t *testing.T) { out := renderText(t, res, nil) - assert.Contains(t, out, "Workspace root") + assert.Contains(t, out, "uv workspace root") assert.Contains(t, out, "/repo") assert.Contains(t, out, "Root pyproject.toml") assert.Contains(t, out, "constraints updated (backup: /repo/pyproject.toml.bak)") @@ -188,7 +188,7 @@ func TestRenderSuccessStandaloneOmitsWorkspaceRows(t *testing.T) { out := renderText(t, res, nil) - assert.NotContains(t, out, "Workspace root") + assert.NotContains(t, out, "uv workspace root") assert.NotContains(t, out, "Root pyproject.toml") } @@ -198,7 +198,7 @@ func TestRenderDryRunWorkspaceMember(t *testing.T) { res.DryRun = true res.Plan = &libslocalenv.Plan{WouldWrite: "/repo/a/pyproject.toml", WouldWriteWorkspace: "/repo/pyproject.toml"} out := renderText(t, res, nil) - assert.Contains(t, out, "Plan: /repo/pyproject.toml (workspace root)") + assert.Contains(t, out, "Plan: /repo/pyproject.toml (uv workspace root)") } func TestRenderSuccessWorkspaceMemberUnchangedRoot(t *testing.T) { diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go index d2671ced95d..5b1a85ba161 100644 --- a/libs/localenv/merge.go +++ b/libs/localenv/merge.go @@ -33,7 +33,7 @@ const ( regionRequiresPython = "requires-python" regionDatabricksConnect = "databricks-connect" regionToolUv = "tool.uv.constraint-dependencies" - regionWorkspaceToolUv = regionToolUv + " (workspace root)" + regionWorkspaceToolUv = regionToolUv + " (uv workspace root)" regionDatabricksEnvironment = "tool.databricks.environment" ) diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 37362caf3b2..96f86365319 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -217,7 +217,7 @@ func (p *Pipeline) run(ctx context.Context) error { p.res.WorkspaceRoot = filepath.ToSlash(p.workspaceRoot) if !p.Check { if err := ensureWritable(p.workspaceRoot); err != nil { - return p.fail(PhasePreflight, false, NewError(ErrNotWritable, err, "workspace root %s is not writable", filepath.ToSlash(p.workspaceRoot))) + return p.fail(PhasePreflight, false, NewError(ErrNotWritable, err, "uv workspace root %s is not writable", filepath.ToSlash(p.workspaceRoot))) } } } @@ -490,7 +490,7 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, if p.Check { diff := pyprojectDiff(pyprojectFile, baseBytes, merged, greenfield) if root != nil { - diff += pyprojectDiff("workspace root "+pyprojectFile, root.base, root.merged, false) + diff += pyprojectDiff("uv workspace root "+pyprojectFile, root.base, root.merged, false) } plan := &Plan{ WouldWrite: filepath.ToSlash(pyproject), @@ -590,7 +590,7 @@ func (p *Pipeline) applyMerge(_ context.Context, mergedBytes []byte, root *rootM p.res.WorkspaceBackupPath = filepath.ToSlash(rootBackup) } if perr != nil { - perr.Msg = "workspace root: " + perr.Msg + perr.Msg = "uv workspace root: " + perr.Msg return p.fail(PhaseMerge, mutated || rootMutated, perr) } } diff --git a/libs/localenv/result.go b/libs/localenv/result.go index 75b2e8bdc90..89c381a4b07 100644 --- a/libs/localenv/result.go +++ b/libs/localenv/result.go @@ -217,8 +217,8 @@ type Plan struct { WouldBackup string `json:"wouldBackup,omitempty"` // WouldWriteWorkspace and WouldBackupWorkspace name the workspace root // pyproject.toml and its planned backup, for a uv workspace member only. - WouldWriteWorkspace string `json:"wouldWriteWorkspace,omitempty"` - WouldBackupWorkspace string `json:"wouldBackupWorkspace,omitempty"` + WouldWriteWorkspace string `json:"wouldWriteUvWorkspace,omitempty"` + WouldBackupWorkspace string `json:"wouldBackupUvWorkspace,omitempty"` WouldInstallPython string `json:"wouldInstallPython,omitempty"` Diff string `json:"diff"` @@ -325,7 +325,7 @@ type Result struct { // WorkspaceRoot 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. - WorkspaceRoot string `json:"workspaceRoot,omitempty"` + WorkspaceRoot string `json:"uvWorkspaceRoot,omitempty"` PythonResolution PythonResolution `json:"pythonResolution,omitempty"` // PythonInterpreter is the exact interpreter chosen by the installed-Python // fallback, named in the text summary so the user can tell which interpreter @@ -343,7 +343,7 @@ type Result struct { BackupPath string `json:"backupPath,omitempty"` // WorkspaceBackupPath is the backup of the workspace root pyproject.toml, set // only when the run changed that file. - WorkspaceBackupPath string `json:"workspaceBackupPath,omitempty"` + WorkspaceBackupPath string `json:"uvWorkspaceBackupPath,omitempty"` // DurationMs is the pipeline's wall time in milliseconds (spec §6). It covers the // CLI pipeline only; the extension measures its own end-to-end latency (process // spawn, interpreter adoption) separately. From a5a514b200f20b723b27b255afa14c05199f9a9d Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 12 Oct 2026 18:13:13 +0200 Subject: [PATCH 16/17] localenv: stop on an interrupt during workspace discovery; uv-prefix the names - An interrupt while uv workspace dir runs no longer falls back to the standalone behavior: with cached constraints, that run went on to rewrite the member pyproject.toml before it reported E_CANCELED. - Rename the warning to W_UV_WORKSPACE_CONSTRAINTS_REPLACED and the Go fields to match the uv-prefixed JSON names. Co-authored-by: Isaac --- cmd/environments/output.go | 12 +++---- cmd/environments/output_test.go | 8 ++--- libs/localenv/pipeline.go | 15 +++++--- libs/localenv/result.go | 22 ++++++------ libs/localenv/workspace_test.go | 64 ++++++++++++++++++++++++++------- 5 files changed, 82 insertions(+), 39 deletions(-) diff --git a/cmd/environments/output.go b/cmd/environments/output.go index a5bdf3c05b5..f58a72b56ea 100644 --- a/cmd/environments/output.go +++ b/cmd/environments/output.go @@ -76,8 +76,8 @@ func renderResult(ctx context.Context, cmd *cobra.Command, res *libslocalenv.Res if res.DryRun { if res.Plan != nil { cmdio.LogString(ctx, "Plan: "+res.Plan.WouldWrite) - if res.Plan.WouldWriteWorkspace != "" { - cmdio.LogString(ctx, "Plan: "+res.Plan.WouldWriteWorkspace+" (uv workspace root)") + if res.Plan.WouldWriteUvWorkspace != "" { + cmdio.LogString(ctx, "Plan: "+res.Plan.WouldWriteUvWorkspace+" (uv workspace root)") } for _, region := range res.Plan.ChangedRegions { cmdio.LogString(ctx, " changed region: "+region) @@ -118,14 +118,14 @@ func renderSuccess(ctx context.Context, res *libslocalenv.Result) { pyprojectDetail = "updated (backup: " + res.BackupPath + ")" } cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "pyproject.toml", pyprojectDetail)) - if res.WorkspaceRoot != "" { + if res.UvWorkspaceRoot != "" { // The root file changes only when its constraint block changes, and every // change writes a backup; --no-constraints and a no-op re-run leave it alone. rootDetail := "unchanged" - if res.WorkspaceBackupPath != "" { - rootDetail = "constraints updated (backup: " + res.WorkspaceBackupPath + ")" + if res.UvWorkspaceBackupPath != "" { + rootDetail = "constraints updated (backup: " + res.UvWorkspaceBackupPath + ")" } - cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "uv workspace root", res.WorkspaceRoot)) + cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "uv workspace root", res.UvWorkspaceRoot)) cmdio.LogString(ctx, fmt.Sprintf(" %-20s%s", "Root pyproject.toml", rootDetail)) } renderInstalledPythonFallback(ctx, res) diff --git a/cmd/environments/output_test.go b/cmd/environments/output_test.go index cad5c5f4236..05efa5a0305 100644 --- a/cmd/environments/output_test.go +++ b/cmd/environments/output_test.go @@ -170,8 +170,8 @@ func TestRenderSuccessWorkspaceMember(t *testing.T) { res.OK = true res.Resolved = &libslocalenv.ResolvedInfo{PythonVersion: "3.12"} res.VenvPath = "../../.venv" - res.WorkspaceRoot = "/repo" - res.WorkspaceBackupPath = "/repo/pyproject.toml.bak" + res.UvWorkspaceRoot = "/repo" + res.UvWorkspaceBackupPath = "/repo/pyproject.toml.bak" out := renderText(t, res, nil) @@ -196,7 +196,7 @@ func TestRenderDryRunWorkspaceMember(t *testing.T) { res := libslocalenv.NewResult() res.OK = true res.DryRun = true - res.Plan = &libslocalenv.Plan{WouldWrite: "/repo/a/pyproject.toml", WouldWriteWorkspace: "/repo/pyproject.toml"} + res.Plan = &libslocalenv.Plan{WouldWrite: "/repo/a/pyproject.toml", WouldWriteUvWorkspace: "/repo/pyproject.toml"} out := renderText(t, res, nil) assert.Contains(t, out, "Plan: /repo/pyproject.toml (uv workspace root)") } @@ -206,7 +206,7 @@ func TestRenderSuccessWorkspaceMemberUnchangedRoot(t *testing.T) { res := libslocalenv.NewResult() res.OK = true res.VenvPath = "../../.venv" - res.WorkspaceRoot = "/repo" + res.UvWorkspaceRoot = "/repo" out := renderText(t, res, nil) diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 96f86365319..89626533eb7 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -209,12 +209,17 @@ func (p *Pipeline) run(ctx context.Context) error { // https://docs.astral.sh/uv/concepts/projects/workspaces/ var rootRel string p.workspaceRoot, rootRel = p.discoverWorkspaceRoot(ctx) + // A discovery failure keeps the standalone behavior, but an interrupt must + // not: the run would go on to merge as a standalone project and write files. + if err := ctx.Err(); err != nil { + 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.WorkspaceRoot = filepath.ToSlash(p.workspaceRoot) + p.res.UvWorkspaceRoot = filepath.ToSlash(p.workspaceRoot) if !p.Check { if err := ensureWritable(p.workspaceRoot); err != nil { return p.fail(PhasePreflight, false, NewError(ErrNotWritable, err, "uv workspace root %s is not writable", filepath.ToSlash(p.workspaceRoot))) @@ -508,9 +513,9 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, // Unlike the member file, the root is reported only when the run changes // it; Result.WorkspaceRoot already names the root. if root != nil && root.changed { - plan.WouldWriteWorkspace = filepath.ToSlash(root.path) + plan.WouldWriteUvWorkspace = filepath.ToSlash(root.path) if backupName, statErr := p.plannedBackupName(p.workspaceRoot); statErr == nil { - plan.WouldBackupWorkspace = filepath.ToSlash(backupName) + plan.WouldBackupUvWorkspace = filepath.ToSlash(backupName) } } p.res.Plan = plan @@ -546,7 +551,7 @@ func (p *Pipeline) planRootMerge(deps []string, opts MergeOptions) (*rootMerge, // bundle) wrote them or the user did, and the new ones apply to every member. if replaced { p.res.Warnings = append(p.res.Warnings, Warning{ - Code: WarnWorkspaceConstraintsReplaced, + Code: WarnUvWorkspaceConstraintsReplaced, Message: fmt.Sprintf("the constraint pins in %s are replaced; all members of this uv workspace share them", filepath.ToSlash(path)), }) } @@ -587,7 +592,7 @@ func (p *Pipeline) applyMerge(_ context.Context, mergedBytes []byte, root *rootM // when either write changed it. rootBackup, rootMutated, perr := p.writePyproject(root.path, root.merged, false) if rootBackup != "" { - p.res.WorkspaceBackupPath = filepath.ToSlash(rootBackup) + p.res.UvWorkspaceBackupPath = filepath.ToSlash(rootBackup) } if perr != nil { perr.Msg = "uv workspace root: " + perr.Msg diff --git a/libs/localenv/result.go b/libs/localenv/result.go index 89c381a4b07..2ceb7dc6bbc 100644 --- a/libs/localenv/result.go +++ b/libs/localenv/result.go @@ -215,12 +215,12 @@ type ResolvedInfo struct { type Plan struct { WouldWrite string `json:"wouldWrite"` WouldBackup string `json:"wouldBackup,omitempty"` - // WouldWriteWorkspace and WouldBackupWorkspace name the workspace root + // WouldWriteUvWorkspace and WouldBackupUvWorkspace name the workspace root // pyproject.toml and its planned backup, for a uv workspace member only. - WouldWriteWorkspace string `json:"wouldWriteUvWorkspace,omitempty"` - WouldBackupWorkspace string `json:"wouldBackupUvWorkspace,omitempty"` - WouldInstallPython string `json:"wouldInstallPython,omitempty"` - Diff string `json:"diff"` + WouldWriteUvWorkspace string `json:"wouldWriteUvWorkspace,omitempty"` + WouldBackupUvWorkspace string `json:"wouldBackupUvWorkspace,omitempty"` + WouldInstallPython string `json:"wouldInstallPython,omitempty"` + Diff string `json:"diff"` ChangedRegions []string `json:"-"` } @@ -297,11 +297,11 @@ const ( // the user's own pyproject pins it (constraints-only mode) — so it agrees with the // validate hard-fail, which keys on the installed venv rather than the mode. WarnStandalonePysparkConflict = "W_STANDALONE_PYSPARK_CONFLICT" - // WarnWorkspaceConstraintsReplaced: the project is a uv workspace member and + // WarnUvWorkspaceConstraintsReplaced: the project is a uv workspace member and // the run replaced constraint pins the workspace root already had, from an // earlier run (for example for another bundle) or written by the user. Every // member of the workspace now resolves against the new pins. - WarnWorkspaceConstraintsReplaced = "W_WORKSPACE_CONSTRAINTS_REPLACED" + WarnUvWorkspaceConstraintsReplaced = "W_UV_WORKSPACE_CONSTRAINTS_REPLACED" ) // Result is the full outcome of a sync run and the root of the --json object @@ -322,10 +322,10 @@ type Result struct { Greenfield bool `json:"greenfield"` Plan *Plan `json:"plan,omitempty"` VenvPath string `json:"venvPath,omitempty"` - // WorkspaceRoot is the uv workspace root (absolute, slash form) when the + // 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. - WorkspaceRoot string `json:"uvWorkspaceRoot,omitempty"` + UvWorkspaceRoot string `json:"uvWorkspaceRoot,omitempty"` PythonResolution PythonResolution `json:"pythonResolution,omitempty"` // PythonInterpreter is the exact interpreter chosen by the installed-Python // fallback, named in the text summary so the user can tell which interpreter @@ -341,9 +341,9 @@ type Result struct { Warnings []Warning `json:"warnings"` Error *PipelineError `json:"error"` BackupPath string `json:"backupPath,omitempty"` - // WorkspaceBackupPath is the backup of the workspace root pyproject.toml, set + // UvWorkspaceBackupPath is the backup of the workspace root pyproject.toml, set // only when the run changed that file. - WorkspaceBackupPath string `json:"uvWorkspaceBackupPath,omitempty"` + UvWorkspaceBackupPath string `json:"uvWorkspaceBackupPath,omitempty"` // DurationMs is the pipeline's wall time in milliseconds (spec §6). It covers the // CLI pipeline only; the extension measures its own end-to-end latency (process // spawn, interpreter adoption) separately. diff --git a/libs/localenv/workspace_test.go b/libs/localenv/workspace_test.go index d52d6488200..084e04ad503 100644 --- a/libs/localenv/workspace_test.go +++ b/libs/localenv/workspace_test.go @@ -1,6 +1,7 @@ package localenv import ( + "context" "errors" "os" "path/filepath" @@ -120,10 +121,10 @@ func TestPipelineWorkspaceMember(t *testing.T) { assert.Contains(t, string(memberBytes), `"databricks-connect~=17.2.0"`) assert.Equal(t, "../../.venv", res.VenvPath) - assert.Equal(t, filepath.ToSlash(root), res.WorkspaceRoot) - assert.Equal(t, filepath.ToSlash(filepath.Join(root, backupFile)), res.WorkspaceBackupPath) + assert.Equal(t, filepath.ToSlash(root), res.UvWorkspaceRoot) + assert.Equal(t, filepath.ToSlash(filepath.Join(root, backupFile)), res.UvWorkspaceBackupPath) assert.Equal(t, filepath.ToSlash(filepath.Join(member, backupFile)), res.BackupPath) - assert.NotContains(t, codes(res.Warnings), WarnWorkspaceConstraintsReplaced, "first run must not warn") + assert.NotContains(t, codes(res.Warnings), WarnUvWorkspaceConstraintsReplaced, "first run must not warn") } func TestPipelineWorkspaceMemberIdempotent(t *testing.T) { @@ -133,8 +134,8 @@ func TestPipelineWorkspaceMemberIdempotent(t *testing.T) { res, err := p.Run(t.Context()) require.NoError(t, err) assert.Empty(t, res.BackupPath) - assert.Empty(t, res.WorkspaceBackupPath) - assert.NotContains(t, codes(res.Warnings), WarnWorkspaceConstraintsReplaced) + assert.Empty(t, res.UvWorkspaceBackupPath) + assert.NotContains(t, codes(res.Warnings), WarnUvWorkspaceConstraintsReplaced) for _, dir := range []string{root, member} { matches, err := filepath.Glob(filepath.Join(dir, "pyproject.toml.*.bak")) require.NoError(t, err) @@ -155,7 +156,7 @@ func TestPipelineWorkspaceConstraintsReplacedWarning(t *testing.T) { res, err := p.Run(t.Context()) require.NoError(t, err) - assert.Contains(t, codes(res.Warnings), WarnWorkspaceConstraintsReplaced) + assert.Contains(t, codes(res.Warnings), WarnUvWorkspaceConstraintsReplaced) } func TestPipelineWorkspaceMemberNoConstraints(t *testing.T) { @@ -168,7 +169,7 @@ func TestPipelineWorkspaceMemberNoConstraints(t *testing.T) { after, err := os.ReadFile(filepath.Join(root, pyprojectFile)) require.NoError(t, err) assert.Equal(t, before, after) - assert.Empty(t, res.WorkspaceBackupPath) + assert.Empty(t, res.UvWorkspaceBackupPath) assert.NoFileExists(t, filepath.Join(root, backupFile)) } @@ -184,8 +185,8 @@ func TestPipelineWorkspaceMemberDryRun(t *testing.T) { assert.Equal(t, before, after) require.NotNil(t, res.Plan) assert.Equal(t, filepath.ToSlash(filepath.Join(member, pyprojectFile)), res.Plan.WouldWrite) - assert.Equal(t, filepath.ToSlash(filepath.Join(root, pyprojectFile)), res.Plan.WouldWriteWorkspace) - assert.Equal(t, filepath.ToSlash(filepath.Join(root, backupFile)), res.Plan.WouldBackupWorkspace) + assert.Equal(t, filepath.ToSlash(filepath.Join(root, pyprojectFile)), res.Plan.WouldWriteUvWorkspace) + assert.Equal(t, filepath.ToSlash(filepath.Join(root, backupFile)), res.Plan.WouldBackupUvWorkspace) assert.Contains(t, res.Plan.Diff, "workspace root") } @@ -286,7 +287,7 @@ func TestPipelineWorkspaceReplacesUserRootPinsWithWarning(t *testing.T) { res, err := p.Run(t.Context()) require.NoError(t, err) - assert.Contains(t, codes(res.Warnings), WarnWorkspaceConstraintsReplaced) + assert.Contains(t, codes(res.Warnings), WarnUvWorkspaceConstraintsReplaced) } func TestApplyMergeRootFailureBeforeAnyWriteIsNotDiskMutated(t *testing.T) { @@ -335,7 +336,44 @@ func TestPipelineWorkspaceMemberDryRunReRunPlansNoRootWrite(t *testing.T) { res, err := p.Run(t.Context()) require.NoError(t, err) require.NotNil(t, res.Plan) - assert.Empty(t, res.Plan.WouldWriteWorkspace) - assert.Empty(t, res.Plan.WouldBackupWorkspace) - assert.NotEmpty(t, res.WorkspaceRoot) + assert.Empty(t, res.Plan.WouldWriteUvWorkspace) + assert.Empty(t, res.Plan.WouldBackupUvWorkspace) + assert.NotEmpty(t, res.UvWorkspaceRoot) +} + +// cancelDiscoveryPM cancels the run while workspace discovery runs, like a +// SIGINT that interrupts `uv workspace dir`. +type cancelDiscoveryPM struct { + fakePM + cancel context.CancelFunc +} + +func (f cancelDiscoveryPM) WorkspaceRoot(ctx context.Context, _ string) (string, error) { + f.cancel() + return "", ctx.Err() +} + +func TestPipelineCancelDuringDiscoveryWritesNothing(t *testing.T) { + p, root, member := newMemberPipeline(t) + // A first run fills the constraint cache, so the canceled run below can + // still fetch; then the member file is reset so a merge would change it. + _, err := p.Run(t.Context()) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(member, pyprojectFile), + []byte("[project]\nname = \"a\"\nversion = \"0.1.0\"\nrequires-python = \">=3.10\"\n"), 0o644)) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + p.PM = cancelDiscoveryPM{fakePM: fakePM{py: "3.12", dbc: "17.2.0", root: root}, cancel: cancel} + before, err := os.ReadFile(filepath.Join(member, pyprojectFile)) + require.NoError(t, err) + + res, err := p.Run(ctx) + require.Error(t, err) + require.NotNil(t, res.Error) + assert.Equal(t, ErrCanceled, res.Error.Code) + assert.False(t, res.Error.DiskMutated) + after, err := os.ReadFile(filepath.Join(member, pyprojectFile)) + require.NoError(t, err) + assert.Equal(t, before, after) } From 045a812f84bade225f95baf6ae941f832ec3c0e8 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Tue, 13 Oct 2026 12:49:02 +0200 Subject: [PATCH 17/17] localenv: skip comments when checking for root pins; skip an unchanged root write A quote inside a comment of an empty constraint-dependencies array no longer counts as a pin, so the run does not warn that pins are replaced. applyMerge no longer reads the workspace root again when the plan found no change. Co-authored-by: Isaac --- libs/localenv/merge.go | 12 ++++++++++-- libs/localenv/merge_test.go | 2 ++ libs/localenv/pipeline.go | 6 ++++-- libs/localenv/workspace_test.go | 17 ++++++++++++++++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go index 5b1a85ba161..ab784564206 100644 --- a/libs/localenv/merge.go +++ b/libs/localenv/merge.go @@ -240,8 +240,16 @@ func hasConstraintDeps(lines []string) bool { continue } last, _ := constraintDepsArrayEnd(lines, i, end) - _, value, _ := strings.Cut(strings.Join(lines[i:last+1], "\n"), "=") - return strings.ContainsAny(value, `"'`) + // Drop comments, so a quote in a comment of an empty array is not a pin. + var value strings.Builder + for _, l := range lines[i : last+1] { + if c := commentStart(l); c >= 0 { + l = l[:c] + } + value.WriteString(l) + } + _, v, _ := strings.Cut(value.String(), "=") + return strings.ContainsAny(v, `"'`) } return false } diff --git a/libs/localenv/merge_test.go b/libs/localenv/merge_test.go index 97b299b5bba..2bf2b741b32 100644 --- a/libs/localenv/merge_test.go +++ b/libs/localenv/merge_test.go @@ -1385,6 +1385,8 @@ func TestMergeToolUvBlockReportsReplacedPins(t *testing.T) { {"user pins", "[tool.uv]\nconstraint-dependencies = [\"urllib3<2\"]\n", true}, {"managed pins", managedMarkerStart + "\n[tool.uv]\nconstraint-dependencies = [\n \"old==1\",\n]\n" + managedMarkerEnd + "\n", true}, {"empty unmarked key", "[tool.uv]\nconstraint-dependencies = []\n", false}, + {"quote only in a comment", "[tool.uv]\nconstraint-dependencies = [\n # \"placeholder\"\n] # 'none yet'\n", false}, + {"pin with a trailing comment", "[tool.uv]\nconstraint-dependencies = [\n \"urllib3<2\", # why\n]\n", true}, {"empty managed block", managedMarkerStart + "\n[tool.uv]\nconstraint-dependencies = [\n]\n" + managedMarkerEnd + "\n", false}, } for _, tc := range tests { diff --git a/libs/localenv/pipeline.go b/libs/localenv/pipeline.go index 89626533eb7..aa64acacb07 100644 --- a/libs/localenv/pipeline.go +++ b/libs/localenv/pipeline.go @@ -207,6 +207,8 @@ func (p *Pipeline) run(ctx context.Context) error { // creates it, and keeps its constraint block in the root pyproject.toml, the // only file uv reads constraint-dependencies from. // https://docs.astral.sh/uv/concepts/projects/workspaces/ + // Every run asks uv, also for a standalone project: only uv applies its member + // globs and exclude rules, and one call costs little next to uv sync. var rootRel string p.workspaceRoot, rootRel = p.discoverWorkspaceRoot(ctx) // A discovery failure keeps the standalone behavior, but an interrupt must @@ -511,7 +513,7 @@ func (p *Pipeline) mergePlan(_ context.Context, pyMinor string, c *Constraints, } } // Unlike the member file, the root is reported only when the run changes - // it; Result.WorkspaceRoot already names the root. + // it; Result.UvWorkspaceRoot already names the root. if root != nil && root.changed { plan.WouldWriteUvWorkspace = filepath.ToSlash(root.path) if backupName, statErr := p.plannedBackupName(p.workspaceRoot); statErr == nil { @@ -587,7 +589,7 @@ func (p *Pipeline) applyMerge(_ context.Context, mergedBytes []byte, root *rootM if perr != nil { return p.fail(PhaseMerge, mutated, perr) } - if root != nil { + if root != nil && root.changed { // The member is written first, so a root failure reports disk as mutated // when either write changed it. rootBackup, rootMutated, perr := p.writePyproject(root.path, root.merged, false) diff --git a/libs/localenv/workspace_test.go b/libs/localenv/workspace_test.go index 084e04ad503..84a96608960 100644 --- a/libs/localenv/workspace_test.go +++ b/libs/localenv/workspace_test.go @@ -301,13 +301,28 @@ func TestApplyMergeRootFailureBeforeAnyWriteIsNotDiskMutated(t *testing.T) { p.res.Phases = initialPhases() missing := filepath.Join(t.TempDir(), pyprojectFile) - err = p.applyMerge(t.Context(), current, &rootMerge{path: missing, merged: []byte("x")}, false) + err = p.applyMerge(t.Context(), current, &rootMerge{path: missing, merged: []byte("x"), changed: true}, false) require.Error(t, err) require.NotNil(t, p.res.Error) assert.False(t, p.res.Error.DiskMutated) assert.Contains(t, p.res.Error.Error(), "workspace root") } +func TestApplyMergeSkipsUnchangedRoot(t *testing.T) { + // The plan already read the root and found no change, so applyMerge must not + // touch it again: a missing root file is not an error here. + _, member := memberLayout(t) + current, err := os.ReadFile(filepath.Join(member, pyprojectFile)) + require.NoError(t, err) + p := &Pipeline{ProjectDir: member, res: NewResult()} + p.res.Phases = initialPhases() + missing := filepath.Join(t.TempDir(), pyprojectFile) + + err = p.applyMerge(t.Context(), current, &rootMerge{path: missing, merged: []byte("x")}, false) + require.NoError(t, err) + assert.Empty(t, p.res.UvWorkspaceBackupPath) +} + func TestDiscoverWorkspaceRootSymlinkChangesDepth(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlinks need extra privileges on Windows")