From b0719c219e04305203a5b826e9b3daf0da788e44 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Fri, 3 Jul 2026 15:19:15 +0200 Subject: [PATCH 1/4] Add local-env formatting-preserving pyproject.toml merge Fourth in the stacked local-env series. merge.go rewrites only the env-owned sections of a pyproject.toml and preserves every other byte (comments, ordering, whitespace, CRLF). It updates requires-python and the databricks-connect pin in place, and maintains a marker-bracketed managed [tool.uv] constraint block. The operation is idempotent: feeding its own output back in is byte-identical. RenderFreshPyproject produces a complete managed file for a greenfield project. Two correctness properties this file has to get right, both covered by tests that parse the result as TOML rather than asserting on strings: - When the user's pyproject.toml already has a [tool.uv] table with a non-constraint key, the managed constraint-dependencies nests header-less inside that table instead of emitting a second [tool.uv] header (two headers for one table is invalid TOML that uv rejects). - Single- vs multi-line constraint-dependencies detection tracks real bracket depth outside strings and comments, so an opening line that contains a "]" inside an element (e.g. "requests[security]~=2.0") or a trailing comment is not misread as single-line and mis-stripped. Depends on the constraints PR for the Constraints type. Still dormant. Co-authored-by: Isaac --- libs/localenv/merge.go | 445 ++++++++++++++++++++++++++++++++++++ libs/localenv/merge_test.go | 322 ++++++++++++++++++++++++++ 2 files changed, 767 insertions(+) create mode 100644 libs/localenv/merge.go create mode 100644 libs/localenv/merge_test.go diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go new file mode 100644 index 0000000000..ca873b5deb --- /dev/null +++ b/libs/localenv/merge.go @@ -0,0 +1,445 @@ +package localenv + +import ( + "fmt" + "regexp" + "strings" +) + +// managedMarkerStart and managedMarkerEnd bracket the region of pyproject.toml that +// "databricks local-env python sync" owns. Everything between them is rewritten on each merge; +// everything outside is preserved byte-for-byte. +const ( + managedMarkerStart = "# managed by databricks local-env python sync — do not edit" + managedMarkerEnd = "# end managed by databricks local-env python sync" +) + +// Region names reported back to the caller via MergeManaged's regions return value. +const ( + regionRequiresPython = "requires-python" + regionDatabricksConnect = "databricks-connect" + regionToolUv = "tool.uv.constraint-dependencies" +) + +var ( + // tableHeaderRe matches a TOML table header line such as "[project]" or "[tool.uv]". + tableHeaderRe = regexp.MustCompile(`^\s*\[[^\]]+\]\s*$`) + // requiresPythonRe captures the leading whitespace of a requires-python assignment so it + // can be preserved when the value is replaced. + requiresPythonRe = regexp.MustCompile(`^(\s*)requires-python\s*=`) +) + +// MergeManaged applies the three managed transforms to target, preserving every other +// byte (comments, ordering, whitespace). It returns the merged bytes and the list of +// regions that actually changed. The operation is idempotent: feeding its own output +// back in produces identical bytes. +func MergeManaged(target []byte, c Constraints) (merged []byte, regions []string, err error) { + s := string(target) + + // Detect and normalize line endings. We process on "\n" and restore "\r\n" on exit. + crlf := strings.Contains(s, "\r\n") + if crlf { + s = strings.ReplaceAll(s, "\r\n", "\n") + } + + lines := strings.Split(s, "\n") + + lines, rpChanged := mergeRequiresPython(lines, c.RequiresPython) + if rpChanged { + regions = append(regions, regionRequiresPython) + } + + lines, dbcChanged := mergeDatabricksConnect(lines, c.DatabricksConnect) + if dbcChanged { + regions = append(regions, regionDatabricksConnect) + } + + lines, uvChanged := mergeToolUv(lines, c.ConstraintDeps) + if uvChanged { + regions = append(regions, regionToolUv) + } + + out := strings.Join(lines, "\n") + if crlf { + out = strings.ReplaceAll(out, "\n", "\r\n") + } + return []byte(out), regions, nil +} + +// tableBounds returns the line index of the header matching name (e.g. "[project]") and +// the index of the first line after the table body (the next table header or EOF). If the +// table is absent, found is false. +func tableBounds(lines []string, name string) (header, end int, found bool) { + header = -1 + for i, line := range lines { + if strings.TrimSpace(line) == name { + header = i + break + } + } + if header == -1 { + return -1, -1, false + } + end = len(lines) + for i := header + 1; i < len(lines); i++ { + if tableHeaderRe.MatchString(lines[i]) { + end = i + break + } + } + return header, end, true +} + +// mergeRequiresPython replaces the value of requires-python within [project], preserving +// the line's leading whitespace. If the key is absent, it is inserted directly under the +// [project] header. Returns whether the line slice changed. +func mergeRequiresPython(lines []string, value string) ([]string, bool) { + header, end, found := tableBounds(lines, "[project]") + if !found { + return lines, false + } + + want := func(indent string) string { + return fmt.Sprintf(`%srequires-python = "%s"`, indent, value) + } + + for i := header + 1; i < end; i++ { + m := requiresPythonRe.FindStringSubmatch(lines[i]) + if m == nil { + continue + } + replacement := want(m[1]) + if lines[i] == replacement { + return lines, false + } + lines[i] = replacement + return lines, true + } + + // Key absent: insert directly under the [project] header. + inserted := make([]string, 0, len(lines)+1) + inserted = append(inserted, lines[:header+1]...) + inserted = append(inserted, want("")) + inserted = append(inserted, lines[header+1:]...) + return inserted, true +} + +// dbconnectLineRe captures, for a line holding a databricks-connect dependency element: +// (1) the leading whitespace, and (3) any trailing comma (with optional trailing space), +// so that indentation and comma style are preserved when the quoted token is replaced. +var dbconnectLineRe = regexp.MustCompile(`^(\s*)"databricks-connect[^"]*"(\s*,?\s*)$`) + +// mergeDatabricksConnect replaces the databricks-connect element inside +// [dependency-groups].dev. It handles both the multi-line array form (one element per +// line) and the single-line array form (dev = ["databricks-connect~=..."]). +// An empty value (constraints-only mode) is a no-op: the user's dev group is left +// untouched rather than having its databricks-connect pin blanked out. +func mergeDatabricksConnect(lines []string, value string) ([]string, bool) { + if value == "" { + return lines, false + } + header, end, found := tableBounds(lines, "[dependency-groups]") + if !found { + return lines, false + } + + for i := header + 1; i < end; i++ { + // Multi-line element form: a standalone line holding only the quoted token. + if m := dbconnectLineRe.FindStringSubmatch(lines[i]); m != nil { + replacement := fmt.Sprintf(`%s"%s"%s`, m[1], value, m[2]) + if lines[i] == replacement { + return lines, false + } + lines[i] = replacement + return lines, true + } + // Single-line array form: replace the quoted databricks-connect token in place. + if strings.Contains(lines[i], `"databricks-connect`) { + replaced := dbconnectTokenRe.ReplaceAllString(lines[i], `"`+value+`"`) + if replaced == lines[i] { + return lines, false + } + lines[i] = replaced + return lines, true + } + } + return lines, false +} + +// dbconnectTokenRe matches a quoted databricks-connect element anywhere in a line, used +// for the single-line array form. +var dbconnectTokenRe = regexp.MustCompile(`"databricks-connect[^"]*"`) + +// mergeToolUv rewrites the managed [tool.uv] constraint-dependencies block. If a +// marker-bracketed block already exists, its contents are replaced in place. Otherwise any +// plain [tool.uv] table is removed and a fresh marker-bracketed block is appended at EOF. +func mergeToolUv(lines, deps []string) ([]string, bool) { + start, stop, found := markerBounds(lines) + if found { + // Replace the existing managed region in place. Whether it owns a [tool.uv] + // header depends on whether it sits inside a user-authored [tool.uv] table: + // a header-less region attached to the user table stays header-less on + // re-merge, so idempotency holds. + block := renderToolUvBlock(deps, !markerAttachedToToolUv(lines, start)) + existing := lines[start : stop+1] + if equalLines(existing, block) { + return lines, false + } + out := make([]string, 0, len(lines)-(stop-start+1)+len(block)) + out = append(out, lines[:start]...) + out = append(out, block...) + out = append(out, lines[stop+1:]...) + return out, true + } + + // No managed block yet: reconcile any plain [tool.uv] table. + if header, end, ok := tableBounds(lines, "[tool.uv]"); ok { + if toolUvHasOnlyConstraintDeps(lines, header, end) { + // The table is effectively ours (only constraint-dependencies, from a + // pre-marker run): drop it whole and append a fresh standalone block. + out := make([]string, 0, len(lines)) + out = append(out, lines[:header]...) + out = append(out, lines[end:]...) + return appendManagedBlock(out, renderToolUvBlock(deps, true)), true + } + // The table holds user-authored keys: strip our stale constraint-dependencies, + // then insert a header-less managed block INSIDE the existing table. Emitting a + // second "[tool.uv]" header (as a standalone block would) is invalid TOML — + // toml.Decode and uv both reject a table defined twice. + lines = removeConstraintDeps(lines, header, end) + header, end, _ = tableBounds(lines, "[tool.uv]") + // Insert after the last non-blank line of the table body so the managed block + // stays under [tool.uv] and any blank line separating the next table is kept. + insertAt := end + for insertAt > header+1 && strings.TrimSpace(lines[insertAt-1]) == "" { + insertAt-- + } + block := renderToolUvBlock(deps, false) + out := make([]string, 0, len(lines)+len(block)) + out = append(out, lines[:insertAt]...) + out = append(out, block...) + out = append(out, lines[insertAt:]...) + return out, true + } + + // No [tool.uv] at all: append a fresh standalone managed block at EOF. + return appendManagedBlock(lines, renderToolUvBlock(deps, true)), 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 +// [tool.uv] header, because a second header for the same table is invalid TOML. +func markerAttachedToToolUv(lines []string, start int) bool { + for i := start - 1; i >= 0; i-- { + if tableHeaderRe.MatchString(lines[i]) { + return strings.TrimSpace(lines[i]) == "[tool.uv]" + } + } + return false +} + +// constraintDepsRe matches the start of a constraint-dependencies assignment within a +// [tool.uv] table, capturing its leading whitespace. +var constraintDepsRe = regexp.MustCompile(`^\s*constraint-dependencies\s*=`) + +// bracketDepthDelta returns the net change in "[" nesting contributed by line. +// It scans outside TOML strings and stops at an unquoted "#" comment, so a "]" +// inside a string element (e.g. "requests[security]==2.0") or a trailing comment +// does not affect the count. It underpins single- vs multi-line array detection; +// testing strings.Contains(line, "]") instead misreads such lines and corrupts +// the merge. +func bracketDepthDelta(line string) int { + delta := 0 + var quote byte // 0 = outside a string; otherwise the opening quote rune + for i := 0; i < len(line); i++ { + c := line[i] + if quote != 0 { + if c == '\\' && quote == '"' { + i++ // skip the escaped char inside a basic string + continue + } + if c == quote { + quote = 0 + } + continue + } + switch c { + case '"', '\'': + quote = c + case '#': + return delta // comment tail: ignore the rest of the line + case '[': + delta++ + case ']': + delta-- + } + } + return delta +} + +// constraintDepsArrayEndDelta reports whether the constraint-dependencies array +// opening at lines[start] spans multiple lines, and if so the index of its last +// line. A single-line array (brackets balanced on the opening line) returns +// (start, false). +func constraintDepsArrayEnd(lines []string, start, limit int) (last int, multiline bool) { + depth := bracketDepthDelta(lines[start]) + if depth <= 0 { + return start, false + } + for j := start + 1; j < limit; j++ { + depth += bracketDepthDelta(lines[j]) + if depth <= 0 { + return j, true + } + } + return limit - 1, true +} + +// toolUvHasOnlyConstraintDeps reports whether the [tool.uv] table body spanning +// (header, end) contains no meaningful key other than constraint-dependencies. Blank lines +// and comment-only lines are ignored when deciding "only". +func toolUvHasOnlyConstraintDeps(lines []string, header, end int) bool { + for i := header + 1; i < end; i++ { + trimmed := strings.TrimSpace(lines[i]) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if !constraintDepsRe.MatchString(lines[i]) { + return false + } + // Skip the continuation lines of a multi-line array so the whole managed + // key counts as ignorable (mirrors removeConstraintDeps). + last, multiline := constraintDepsArrayEnd(lines, i, end) + if multiline { + i = last + } + } + return true +} + +// removeConstraintDeps strips a constraint-dependencies key from the [tool.uv] table body +// spanning (header, end), leaving the table header and all other user keys in place. It +// handles both the single-line array form and the multi-line array form (the value spans +// several lines until the array's brackets balance). +func removeConstraintDeps(lines []string, header, end int) []string { + for i := header + 1; i < end; i++ { + if !constraintDepsRe.MatchString(lines[i]) { + continue + } + last, _ := constraintDepsArrayEnd(lines, i, end) + end2 := last + 1 + out := make([]string, 0, len(lines)-(end2-i)) + out = append(out, lines[:i]...) + out = append(out, lines[end2:]...) + return out + } + return lines +} + +// markerBounds returns the indices of the managed marker start and end lines, if present. +func markerBounds(lines []string) (start, stop int, found bool) { + start, stop = -1, -1 + for i, line := range lines { + if strings.TrimSpace(line) == managedMarkerStart { + start = i + break + } + } + if start == -1 { + return -1, -1, false + } + for i := start + 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == managedMarkerEnd { + stop = i + break + } + } + if stop == -1 { + return -1, -1, false + } + return start, stop, true +} + +// renderToolUvBlock builds the marker-bracketed managed block lines (no surrounding +// blank lines). When withHeader is true it emits its own "[tool.uv]" table header +// (standalone block appended at EOF); when false it omits the header so the block can +// be nested inside a user-authored [tool.uv] table without defining the table twice. +func renderToolUvBlock(deps []string, withHeader bool) []string { + block := []string{managedMarkerStart} + if withHeader { + block = append(block, "[tool.uv]") + } + block = append(block, "constraint-dependencies = [") + for _, d := range deps { + block = append(block, fmt.Sprintf(" %q,", d)) + } + block = append(block, "]", managedMarkerEnd) + return block +} + +// appendManagedBlock appends block to lines, ensuring exactly one blank line separates it +// from prior content and the file ends with a single trailing newline. +func appendManagedBlock(lines, block []string) []string { + // strings.Split on a trailing "\n" leaves a final empty element; drop trailing empty + // lines so we control the spacing precisely. + for len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + + out := make([]string, 0, len(lines)+len(block)+2) + out = append(out, lines...) + if len(out) > 0 { + out = append(out, "") // exactly one blank line before the managed block + } + out = append(out, block...) + out = append(out, "") // trailing newline after final join + return out +} + +// equalLines reports whether two line slices are identical. +func equalLines(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// freshProjectVersion is the placeholder version written into a greenfield +// [project] table. uv rejects a [project] table that has neither project.version +// nor project.dynamic containing "version", even for a non-distributed local +// environment, so a concrete value is required for `uv sync` to succeed. +const freshProjectVersion = "0.0.0" + +// RenderFreshPyproject produces a complete managed pyproject.toml for a project that has +// none, with [project], [dependency-groups].dev (carrying the databricks-connect pin), and +// the marker-bracketed [tool.uv] constraint block. When c.DatabricksConnect is empty +// (constraints-only mode) the dev group is emitted empty rather than with a blank entry. +func RenderFreshPyproject(projectName string, c Constraints) []byte { + var b strings.Builder + b.WriteString("[project]\n") + fmt.Fprintf(&b, "name = %q\n", projectName) + // uv requires project.version when a [project] table is present. + fmt.Fprintf(&b, "version = %q\n", freshProjectVersion) + fmt.Fprintf(&b, "requires-python = %q\n", c.RequiresPython) + b.WriteString("\n") + b.WriteString("[dependency-groups]\n") + if c.DatabricksConnect != "" { + b.WriteString("dev = [\n") + fmt.Fprintf(&b, " %q,\n", c.DatabricksConnect) + b.WriteString("]\n") + } else { + b.WriteString("dev = []\n") + } + b.WriteString("\n") + for _, line := range renderToolUvBlock(c.ConstraintDeps, true) { + b.WriteString(line) + b.WriteString("\n") + } + return []byte(b.String()) +} diff --git a/libs/localenv/merge_test.go b/libs/localenv/merge_test.go new file mode 100644 index 0000000000..ea273b68eb --- /dev/null +++ b/libs/localenv/merge_test.go @@ -0,0 +1,322 @@ +package localenv + +import ( + "strings" + "testing" + + "github.com/BurntSushi/toml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// requireValidTOML fails the test if b is not parseable TOML. String-only +// assertions miss structural corruption such as a table header emitted twice +// ("Key 'tool.uv' has already been defined"), which uv also rejects on sync. +func requireValidTOML(t *testing.T, b []byte) { + t.Helper() + var v map[string]any + _, err := toml.Decode(string(b), &v) + require.NoError(t, err, "merged output must be valid TOML:\n%s", b) +} + +func testConstraints() Constraints { + return Constraints{ + RequiresPython: "==3.12.*", + DatabricksConnect: "databricks-connect~=17.2.0", + ConstraintDeps: []string{"pydantic~=2.10.6", "anyio~=4.6.2"}, + } +} + +func TestMergeReplacesRequiresPythonPreservingComments(t *testing.T) { + in := []byte(`[project] +name = "demo" +# keep this comment +requires-python = ">=3.10" + +[dependency-groups] +dev = [ + "databricks-connect~=16.0.0", + "pytest~=8.0", +] +`) + out, regions, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + assert.Contains(t, string(out), `requires-python = "==3.12.*"`) + assert.Contains(t, string(out), "# keep this comment") + assert.Contains(t, string(out), `"databricks-connect~=17.2.0",`) + assert.Contains(t, string(out), `"pytest~=8.0",`) + assert.Contains(t, regions, "requires-python") + assert.Contains(t, regions, "databricks-connect") + assert.Contains(t, regions, "tool.uv.constraint-dependencies") + assert.Contains(t, string(out), "pydantic~=2.10.6") +} + +func TestMergeIsIdempotent(t *testing.T) { + in := []byte(`[project] +requires-python = ">=3.10" + +[dependency-groups] +dev = [ + "databricks-connect~=16.0.0", +] +`) + once, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + twice, _, err := MergeManaged(once, testConstraints()) + require.NoError(t, err) + assert.Equal(t, string(once), string(twice)) +} + +func TestMergeInsertsRequiresPythonWhenMissing(t *testing.T) { + in := []byte(`[project] +name = "demo" + +[dependency-groups] +dev = ["databricks-connect~=16.0.0"] +`) + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + assert.Contains(t, string(out), `requires-python = "==3.12.*"`) +} + +func TestMergeReplacesExistingManagedToolUvBlock(t *testing.T) { + in := []byte(`[project] +requires-python = ">=3.10" + +[dependency-groups] +dev = ["databricks-connect~=16.0.0"] + +` + managedMarkerStart + ` +[tool.uv] +constraint-dependencies = [ + "stale~=1.0.0", +] +` + managedMarkerEnd + ` +`) + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + assert.NotContains(t, string(out), "stale~=1.0.0") + assert.Contains(t, string(out), "pydantic~=2.10.6") + // Only one managed block remains. + assert.Equal(t, 1, countOccurrences(string(out), managedMarkerStart)) +} + +func TestMergePreservesCRLF(t *testing.T) { + in := []byte("[project]\r\nrequires-python = \">=3.10\"\r\n\r\n[dependency-groups]\r\ndev = [\"databricks-connect~=16.0.0\"]\r\n") + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + assert.Contains(t, string(out), "\r\n") + assert.Contains(t, string(out), `requires-python = "==3.12.*"`) + // Merging the CRLF output again must be byte-identical (idempotent under \r\n). + twice, _, err := MergeManaged(out, testConstraints()) + require.NoError(t, err) + assert.Equal(t, string(out), string(twice)) +} + +func TestMergePreservesUserToolUvKeys(t *testing.T) { + in := []byte(`[project] +requires-python = ">=3.10" + +[dependency-groups] +dev = ["databricks-connect~=16.0.0"] + +[tool.uv] +package = true +dev-dependencies = ["ruff"] +`) + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + s := string(out) + assert.Contains(t, s, "[tool.uv]") + assert.Contains(t, s, "package = true") + assert.Contains(t, s, `dev-dependencies = ["ruff"]`) + assert.Contains(t, s, managedMarkerStart) + assert.Contains(t, s, "pydantic~=2.10.6") + // The user's keys must live outside the managed marker block. + start := strings.Index(s, managedMarkerStart) + require.GreaterOrEqual(t, start, 0) + assert.NotContains(t, s[start:], "package = true") + assert.NotContains(t, s[start:], `dev-dependencies = ["ruff"]`) + // The result must be valid TOML: the managed constraint-dependencies nests + // inside the user's [tool.uv] rather than emitting a second [tool.uv] header. + requireValidTOML(t, out) + assert.Equal(t, 1, countOccurrences(s, "[tool.uv]")) + // Merge-twice is byte-identical (header-less managed region stays header-less). + twice, _, err := MergeManaged(out, testConstraints()) + require.NoError(t, err) + assert.Equal(t, s, string(twice)) + requireValidTOML(t, twice) +} + +func TestMergeStripsStaleConstraintDepsFromUserToolUv(t *testing.T) { + in := []byte(`[project] +requires-python = ">=3.10" + +[dependency-groups] +dev = ["databricks-connect~=16.0.0"] + +[tool.uv] +package = true +constraint-dependencies = ["old~=1.0"] +`) + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + s := string(out) + assert.Contains(t, s, "package = true") + // The stale constraint must be gone from the user table; the managed block has the new deps. + assert.NotContains(t, s, "old~=1.0") + assert.Contains(t, s, "pydantic~=2.10.6") + // Valid TOML with a single [tool.uv]: the managed deps nest in the user table. + requireValidTOML(t, out) + assert.Equal(t, 1, countOccurrences(s, "[tool.uv]")) + // Merge-twice is byte-identical. + twice, _, err := MergeManaged(out, testConstraints()) + require.NoError(t, err) + assert.Equal(t, string(out), string(twice)) +} + +func TestMergeRemovesOwnedOnlyToolUv(t *testing.T) { + in := []byte(`[project] +requires-python = ">=3.10" + +[dependency-groups] +dev = ["databricks-connect~=16.0.0"] + +[tool.uv] +constraint-dependencies = ["old~=1.0"] +`) + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + s := string(out) + assert.NotContains(t, s, "old~=1.0") + assert.Contains(t, s, "pydantic~=2.10.6") + // The plain table was removed and replaced by exactly one managed block. + assert.Equal(t, 1, countOccurrences(s, "[tool.uv]")) + assert.Equal(t, 1, countOccurrences(s, managedMarkerStart)) + requireValidTOML(t, out) +} + +func TestMergeRemovesOwnedOnlyMultiLineToolUv(t *testing.T) { + in := []byte(`[project] +requires-python = ">=3.10" + +[dependency-groups] +dev = ["databricks-connect~=16.0.0"] + +[tool.uv] +constraint-dependencies = [ + "old~=1.0", +] +`) + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + s := string(out) + assert.NotContains(t, s, "old~=1.0") + assert.Contains(t, s, "pydantic~=2.10.6") + // The multi-line owned-only table was removed whole, leaving exactly one + // [tool.uv] (inside the managed block) and no stray empty header. + assert.Equal(t, 1, countOccurrences(s, "[tool.uv]")) + assert.Equal(t, 1, countOccurrences(s, managedMarkerStart)) + requireValidTOML(t, out) + // Merge-twice is byte-identical. + twice, _, err := MergeManaged(out, testConstraints()) + require.NoError(t, err) + assert.Equal(t, string(out), string(twice)) +} + +func TestMergeReplacesSingleLineDevArray(t *testing.T) { + in := []byte(`[project] +requires-python = ">=3.10" + +[dependency-groups] +dev = ["databricks-connect~=16.0.0", "pytest~=8.0"] +`) + out, regions, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + // Sibling element and single-line array layout are preserved. + assert.Contains(t, string(out), `dev = ["databricks-connect~=17.2.0", "pytest~=8.0"]`) + assert.Contains(t, regions, "databricks-connect") +} + +func TestMergePreservesMultiLineTrailingComma(t *testing.T) { + in := []byte(`[project] +requires-python = ">=3.10" + +[dependency-groups] +dev = [ + "databricks-connect~=16.0.0", +] +`) + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + // The trailing comma on the managed element is preserved. + assert.Contains(t, string(out), ` "databricks-connect~=17.2.0",`) +} + +func TestRenderFreshPyproject(t *testing.T) { + out := RenderFreshPyproject("demo", testConstraints()) + s := string(out) + assert.Contains(t, s, `name = "demo"`) + assert.Contains(t, s, `requires-python = "==3.12.*"`) + assert.Contains(t, s, `"databricks-connect~=17.2.0",`) + assert.Contains(t, s, managedMarkerStart) + assert.Contains(t, s, managedMarkerEnd) + assert.Contains(t, s, "pydantic~=2.10.6") + // A fresh render is itself a no-op under MergeManaged (already fully managed). + merged, _, err := MergeManaged(out, testConstraints()) + require.NoError(t, err) + assert.Equal(t, s, string(merged)) +} + +func TestMergeStripsMultiLineConstraintDepsWithBracketInFirstElement(t *testing.T) { + // The user's stale constraint-dependencies is a multi-line array whose FIRST + // element line contains a "]" inside an extras spec. A naive + // strings.Contains(line, "]") check would misread this as single-line and + // strip only the first element, orphaning the rest and producing invalid TOML. + in := []byte(`[project] +requires-python = ">=3.10" + +[dependency-groups] +dev = ["databricks-connect~=16.0.0"] + +[tool.uv] +package = true +constraint-dependencies = ["requests[security]~=2.0", + "old-dep~=1.0", +] +`) + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + s := string(out) + // The whole stale array is gone (both the bracket-bearing first element and + // the continuation), replaced by the managed deps. + assert.NotContains(t, s, "requests[security]") + assert.NotContains(t, s, "old-dep~=1.0") + assert.Contains(t, s, "package = true") + assert.Contains(t, s, "pydantic~=2.10.6") + requireValidTOML(t, out) + assert.Equal(t, 1, countOccurrences(s, "[tool.uv]")) + // Idempotent. + twice, _, err := MergeManaged(out, testConstraints()) + require.NoError(t, err) + assert.Equal(t, s, string(twice)) +} + +func TestBracketDepthDeltaIgnoresStringsAndComments(t *testing.T) { + cases := map[string]int{ + `constraint-dependencies = [`: 1, // opens, no close + `constraint-dependencies = ["a", "b"]`: 0, // balanced single-line + `constraint-dependencies = ["requests[sec]~=2",`: 1, // ] inside string ignored + ` "old~=1.0",`: 0, // element line + `]`: -1, // close + `] # trailing note ]`: -1, // ] in comment ignored + `constraint-dependencies = ["x"] # [note]`: 0, // comment ] ignored + } + for line, want := range cases { + assert.Equal(t, want, bracketDepthDelta(line), "line: %q", line) + } +} + +func countOccurrences(s, substr string) int { + return strings.Count(s, substr) +} From 767e3ac0f4d74ec10754749bde739425aaaf7512 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Fri, 3 Jul 2026 19:54:59 +0200 Subject: [PATCH 2/4] Scope pyproject merge to the dev group and preserve inline comments Review of the merge layer found the databricks-connect rewrite was not scoped to [dependency-groups].dev: - It walked every line of [dependency-groups] and rewrote the first databricks-connect element found, so a pin in a sibling group (docs/test) was clobbered instead of the dev entry. It now locates the dev assignment and edits only within that array's line span. - The single-line branch replaced the databricks-connect token anywhere on the dev line, including inside a trailing comment (user content). Replacement is now confined to the array portion (through its closing "]"); the trailing comment is preserved byte-for-byte. - mergeRequiresPython replaced the whole line, dropping an inline comment such as `requires-python = ">=3.10" # maintained by platform team`. It now reattaches the trailing comment, honoring the byte-preservation contract for everything outside the managed value. Adds tests for each: sibling-group untouched, comment not clobbered, inline comment preserved. Co-authored-by: Isaac --- libs/localenv/merge.go | 150 ++++++++++++++++++++++++++++++++---- libs/localenv/merge_test.go | 52 +++++++++++++ 2 files changed, 187 insertions(+), 15 deletions(-) diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go index ca873b5deb..965e73a788 100644 --- a/libs/localenv/merge.go +++ b/libs/localenv/merge.go @@ -99,8 +99,8 @@ func mergeRequiresPython(lines []string, value string) ([]string, bool) { return lines, false } - want := func(indent string) string { - return fmt.Sprintf(`%srequires-python = "%s"`, indent, value) + want := func(indent, comment string) string { + return fmt.Sprintf(`%srequires-python = "%s"%s`, indent, value, comment) } for i := header + 1; i < end; i++ { @@ -108,7 +108,9 @@ func mergeRequiresPython(lines []string, value string) ([]string, bool) { if m == nil { continue } - replacement := want(m[1]) + // Preserve a trailing inline comment; only the value is managed, so + // "requires-python = \"...\" # note" keeps its note. + replacement := want(m[1], trailingComment(lines[i])) if lines[i] == replacement { return lines, false } @@ -119,21 +121,63 @@ func mergeRequiresPython(lines []string, value string) ([]string, bool) { // Key absent: insert directly under the [project] header. inserted := make([]string, 0, len(lines)+1) inserted = append(inserted, lines[:header+1]...) - inserted = append(inserted, want("")) + inserted = append(inserted, want("", "")) inserted = append(inserted, lines[header+1:]...) return inserted, true } +// trailingComment returns the inline TOML comment suffix of a line (including the +// leading whitespace and "#"), or "" if there is none. It ignores "#" characters +// inside a quoted string so a value like requires-python = ">=3.10 # x" is not +// mistaken for a comment. +func trailingComment(line string) string { + var quote byte + for i := 0; i < len(line); i++ { + c := line[i] + if quote != 0 { + if c == '\\' && quote == '"' { + i++ + continue + } + if c == quote { + quote = 0 + } + continue + } + switch c { + case '"', '\'': + quote = c + case '#': + // Include any whitespace immediately preceding the "#". + start := i + for start > 0 && (line[start-1] == ' ' || line[start-1] == '\t') { + start-- + } + return line[start:] + } + } + return "" +} + // dbconnectLineRe captures, for a line holding a databricks-connect dependency element: // (1) the leading whitespace, and (3) any trailing comma (with optional trailing space), // so that indentation and comma style are preserved when the quoted token is replaced. var dbconnectLineRe = regexp.MustCompile(`^(\s*)"databricks-connect[^"]*"(\s*,?\s*)$`) +// devKeyRe matches the start of the dev array assignment within [dependency-groups] +// (e.g. "dev = [" or "dev=["), capturing leading whitespace. Only this key is +// managed; sibling groups such as test/docs are user-owned and left untouched. +var devKeyRe = regexp.MustCompile(`^\s*dev\s*=`) + // mergeDatabricksConnect replaces the databricks-connect element inside -// [dependency-groups].dev. It handles both the multi-line array form (one element per -// line) and the single-line array form (dev = ["databricks-connect~=..."]). +// [dependency-groups].dev only. It handles both the multi-line array form (one +// element per line) and the single-line array form (dev = ["databricks-connect~=..."]). // An empty value (constraints-only mode) is a no-op: the user's dev group is left // untouched rather than having its databricks-connect pin blanked out. +// +// The rewrite is scoped to the dev array's own span (found via bracket depth), so a +// databricks-connect pin sitting in a sibling group (e.g. docs/test) or inside a +// trailing comment on some other line is never clobbered. func mergeDatabricksConnect(lines []string, value string) ([]string, bool) { if value == "" { return lines, false @@ -143,8 +187,38 @@ func mergeDatabricksConnect(lines []string, value string) ([]string, bool) { return lines, false } + // Locate the dev assignment and the line span of its array value. + devStart := -1 for i := header + 1; i < end; i++ { - // Multi-line element form: a standalone line holding only the quoted token. + if devKeyRe.MatchString(lines[i]) { + devStart = i + break + } + } + if devStart == -1 { + return lines, false + } + arrayLast, _ := arrayLineSpan(lines, devStart, end) + + // Single-line form: the whole array is on the dev line itself. Only rewrite + // within the array (through its closing "]"); a trailing comment after it is + // user content and must be left byte-for-byte intact. + if devStart == arrayLast { + line := lines[devStart] + arrayPart, commentPart := splitAtArrayClose(line) + if !strings.Contains(arrayPart, `"databricks-connect`) { + return lines, false + } + replaced := dbconnectTokenRe.ReplaceAllString(arrayPart, `"`+value+`"`) + commentPart + if replaced == line { + return lines, false + } + lines[devStart] = replaced + return lines, true + } + + // Multi-line form: one element per line, between the dev line and the closing "]". + for i := devStart + 1; i <= arrayLast; i++ { if m := dbconnectLineRe.FindStringSubmatch(lines[i]); m != nil { replacement := fmt.Sprintf(`%s"%s"%s`, m[1], value, m[2]) if lines[i] == replacement { @@ -153,17 +227,63 @@ func mergeDatabricksConnect(lines []string, value string) ([]string, bool) { lines[i] = replacement return lines, true } - // Single-line array form: replace the quoted databricks-connect token in place. - if strings.Contains(lines[i], `"databricks-connect`) { - replaced := dbconnectTokenRe.ReplaceAllString(lines[i], `"`+value+`"`) - if replaced == lines[i] { - return lines, false + } + return lines, false +} + +// splitAtArrayClose splits a single-line array assignment into the part up to and +// including the array's closing "]" and the remainder (a trailing comment, if any), +// tracking bracket depth outside quoted strings. This keeps token replacement inside +// the array from touching a trailing comment. If no balanced close is found the whole +// line is returned as the array part. +func splitAtArrayClose(line string) (arrayPart, rest string) { + depth := 0 + var quote byte + opened := false + for i := 0; i < len(line); i++ { + c := line[i] + if quote != 0 { + if c == '\\' && quote == '"' { + i++ + continue + } + if c == quote { + quote = 0 + } + continue + } + switch c { + case '"', '\'': + quote = c + case '[': + depth++ + opened = true + case ']': + depth-- + if opened && depth == 0 { + return line[:i+1], line[i+1:] } - lines[i] = replaced - return lines, true } } - return lines, false + return line, "" +} + +// arrayLineSpan returns the index of the line on which the array opened at +// lines[start] closes (brackets balance), scanning outside strings/comments. A +// single-line array returns start. It bounds in-place edits of an array value to +// the array's own lines. +func arrayLineSpan(lines []string, start, limit int) (last int, multiline bool) { + depth := bracketDepthDelta(lines[start]) + if depth <= 0 { + return start, false + } + for j := start + 1; j < limit; j++ { + depth += bracketDepthDelta(lines[j]) + if depth <= 0 { + return j, true + } + } + return limit - 1, true } // dbconnectTokenRe matches a quoted databricks-connect element anywhere in a line, used diff --git a/libs/localenv/merge_test.go b/libs/localenv/merge_test.go index ea273b68eb..81997245d6 100644 --- a/libs/localenv/merge_test.go +++ b/libs/localenv/merge_test.go @@ -317,6 +317,58 @@ func TestBracketDepthDeltaIgnoresStringsAndComments(t *testing.T) { } } +func TestMergeDatabricksConnectOnlyTouchesDevGroup(t *testing.T) { + // A databricks-connect pin in a sibling group (docs) must be left alone; only + // the dev group's entry is managed. + in := []byte(`[project] +requires-python = ">=3.10" + +[dependency-groups] +docs = ["databricks-connect~=14.3"] +dev = [ + "databricks-connect~=16.0.0", +] +`) + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + s := string(out) + // docs untouched; dev updated to the managed pin. + assert.Contains(t, s, `docs = ["databricks-connect~=14.3"]`) + assert.Contains(t, s, `"databricks-connect~=17.2.0",`) + requireValidTOML(t, out) +} + +func TestMergeDatabricksConnectDoesNotClobberComment(t *testing.T) { + // A databricks-connect token inside a trailing comment is user content and + // must not be rewritten. + in := []byte(`[project] +requires-python = ">=3.10" + +[dependency-groups] +dev = ["pytest"] # keep "databricks-connect~=14.3" for docs +`) + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + s := string(out) + // The comment's databricks-connect~=14.3 is preserved verbatim. + assert.Contains(t, s, `# keep "databricks-connect~=14.3" for docs`) + requireValidTOML(t, out) +} + +func TestMergeRequiresPythonPreservesInlineComment(t *testing.T) { + in := []byte(`[project] +requires-python = ">=3.10" # maintained by platform team + +[dependency-groups] +dev = ["databricks-connect~=16.0.0"] +`) + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + s := string(out) + assert.Contains(t, s, `requires-python = "==3.12.*" # maintained by platform team`) + requireValidTOML(t, out) +} + func countOccurrences(s, substr string) int { return strings.Count(s, substr) } From 532649a20e2ba5ea7129de989e2ee402e2b88028 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Fri, 3 Jul 2026 20:14:44 +0200 Subject: [PATCH 3/4] Handle inline comments on TOML table headers in the pyproject merge Round-2 review found the merge did not tolerate a trailing comment on a table header line (e.g. "[project] # note"). Two consequences: mergeRequiresPython could not find a commented [project] header (managed value silently not updated), and worse, the [dependency-groups] end bound could run past a commented sibling header, so a dev key in a following table was mistaken for [dependency-groups].dev and rewritten. tableHeaderRe now allows a trailing comment and a new headerName helper matches a header by its bracketed name ignoring the comment; both the table lookup and the [tool.uv] attachment check use it. Also documents that line endings are a whole-file property (a CRLF-anywhere file is emitted entirely as CRLF), which is faithful for real single-ending pyproject.toml files. Co-authored-by: Isaac --- libs/localenv/merge.go | 34 ++++++++++++++++++++++++++++------ libs/localenv/merge_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go index 965e73a788..6cbd463965 100644 --- a/libs/localenv/merge.go +++ b/libs/localenv/merge.go @@ -22,8 +22,11 @@ const ( ) var ( - // tableHeaderRe matches a TOML table header line such as "[project]" or "[tool.uv]". - tableHeaderRe = regexp.MustCompile(`^\s*\[[^\]]+\]\s*$`) + // tableHeaderRe matches a TOML table header line such as "[project]" or + // "[tool.uv]", tolerating a trailing inline comment ("[project] # note"). Not + // tolerating the comment would both miss a commented [project] header and let + // a table's end bound run past a commented sibling header into the next table. + tableHeaderRe = regexp.MustCompile(`^\s*\[[^\]]+\]\s*(#.*)?$`) // requiresPythonRe captures the leading whitespace of a requires-python assignment so it // can be preserved when the value is replaced. requiresPythonRe = regexp.MustCompile(`^(\s*)requires-python\s*=`) @@ -36,7 +39,12 @@ var ( func MergeManaged(target []byte, c Constraints) (merged []byte, regions []string, err error) { s := string(target) - // Detect and normalize line endings. We process on "\n" and restore "\r\n" on exit. + // Detect and normalize line endings. We process on "\n" and restore "\r\n" on + // exit. Line endings are treated as a whole-file property: a file that uses + // CRLF anywhere is emitted entirely as CRLF. Real pyproject.toml files use one + // consistent ending, so this is faithful in practice; a file that deliberately + // mixes LF and CRLF would be normalized to CRLF (a benign whitespace-only + // change), which we accept rather than track a terminator per line. crlf := strings.Contains(s, "\r\n") if crlf { s = strings.ReplaceAll(s, "\r\n", "\n") @@ -66,13 +74,27 @@ func MergeManaged(target []byte, c Constraints) (merged []byte, regions []string return []byte(out), regions, nil } +// headerName returns the bracketed table name of a TOML table-header line (e.g. +// "[project]" from " [project] # note"), or "" if the line is not a table header. +// It strips a trailing inline comment so a commented header still matches by name. +func headerName(line string) string { + if !tableHeaderRe.MatchString(line) { + return "" + } + s := strings.TrimSpace(line) + if i := strings.Index(s, "]"); i >= 0 { + return s[:i+1] + } + return s +} + // tableBounds returns the line index of the header matching name (e.g. "[project]") and // the index of the first line after the table body (the next table header or EOF). If the // table is absent, found is false. func tableBounds(lines []string, name string) (header, end int, found bool) { header = -1 for i, line := range lines { - if strings.TrimSpace(line) == name { + if headerName(line) == name { header = i break } @@ -352,8 +374,8 @@ func mergeToolUv(lines, deps []string) ([]string, bool) { // [tool.uv] header, because a second header for the same table is invalid TOML. func markerAttachedToToolUv(lines []string, start int) bool { for i := start - 1; i >= 0; i-- { - if tableHeaderRe.MatchString(lines[i]) { - return strings.TrimSpace(lines[i]) == "[tool.uv]" + if name := headerName(lines[i]); name != "" { + return name == "[tool.uv]" } } return false diff --git a/libs/localenv/merge_test.go b/libs/localenv/merge_test.go index 81997245d6..b8b7602361 100644 --- a/libs/localenv/merge_test.go +++ b/libs/localenv/merge_test.go @@ -369,6 +369,39 @@ dev = ["databricks-connect~=16.0.0"] requireValidTOML(t, out) } +func TestMergeHandlesTableHeaderInlineComments(t *testing.T) { + // Table headers may carry a trailing comment. requires-python under a + // commented [project] must still be updated, and the [dependency-groups] end + // bound must not run past a commented sibling header into another table's dev. + in := []byte(`[project] # package metadata +requires-python = ">=3.10" + +[dependency-groups] +dev = ["databricks-connect~=16.0.0"] + +[tool.custom] # user table +dev = ["databricks-connect==1.0.0"] # must not be managed +`) + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + s := string(out) + // [project].requires-python was found and updated despite the header comment. + assert.Contains(t, s, `requires-python = "==3.12.*"`) + // The real dev group was updated... + assert.Contains(t, s, `"databricks-connect~=17.2.0"`) + // ...but the lookalike dev under [tool.custom] was left untouched. + assert.Contains(t, s, `dev = ["databricks-connect==1.0.0"] # must not be managed`) + requireValidTOML(t, out) +} + +func TestHeaderName(t *testing.T) { + assert.Equal(t, "[project]", headerName("[project]")) + assert.Equal(t, "[project]", headerName(" [project] # note")) + assert.Equal(t, "[tool.uv]", headerName("[tool.uv]#x")) + assert.Empty(t, headerName("requires-python = \"3.12\"")) + assert.Empty(t, headerName("dev = [\"a\"]")) +} + func countOccurrences(s, substr string) int { return strings.Count(s, substr) } From 79113e487e4ce73518cfaf20a815013febaaa0a5 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Fri, 3 Jul 2026 20:26:41 +0200 Subject: [PATCH 4/4] Recognize array-of-tables headers so [tool.uv] bounds stop at its children Round-3 review found tableHeaderRe did not match TOML array-of-tables headers like "[[tool.uv.index]]". A [tool.uv] table's end bound therefore ran through its [[tool.uv.index]] children, and the header-less managed constraint block could be inserted inside the last index item instead of under [tool.uv], producing wrong or invalid uv config. tableHeaderRe now matches both "[...]" and "[[...]]"; headerName returns the full "[[...]]" token so an array-of-tables header is never treated as the same table as its "[...]" parent. Adds a merge test with a [[tool.uv.index]] child. Co-authored-by: Isaac --- libs/localenv/merge.go | 25 ++++++++++++++++++------- libs/localenv/merge_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/libs/localenv/merge.go b/libs/localenv/merge.go index 6cbd463965..9de5f8358d 100644 --- a/libs/localenv/merge.go +++ b/libs/localenv/merge.go @@ -22,11 +22,13 @@ const ( ) var ( - // tableHeaderRe matches a TOML table header line such as "[project]" or - // "[tool.uv]", tolerating a trailing inline comment ("[project] # note"). Not - // tolerating the comment would both miss a commented [project] header and let - // a table's end bound run past a commented sibling header into the next table. - tableHeaderRe = regexp.MustCompile(`^\s*\[[^\]]+\]\s*(#.*)?$`) + // tableHeaderRe matches a TOML table header line: a standard table like + // "[project]" / "[tool.uv]" or an array-of-tables like "[[tool.uv.index]]", + // tolerating a trailing inline comment ("[project] # note"). Recognizing both + // forms matters for bounds: an unrecognized "[[...]]" header would let a + // table's end run past it (e.g. [tool.uv] swallowing its child [[tool.uv.index]] + // items), and a commented header would similarly be missed. + tableHeaderRe = regexp.MustCompile(`^\s*\[\[?[^\]]+\]\]?\s*(#.*)?$`) // requiresPythonRe captures the leading whitespace of a requires-python assignment so it // can be preserved when the value is replaced. requiresPythonRe = regexp.MustCompile(`^(\s*)requires-python\s*=`) @@ -75,13 +77,22 @@ func MergeManaged(target []byte, c Constraints) (merged []byte, regions []string } // headerName returns the bracketed table name of a TOML table-header line (e.g. -// "[project]" from " [project] # note"), or "" if the line is not a table header. -// It strips a trailing inline comment so a commented header still matches by name. +// "[project]" from " [project] # note", or "[[tool.uv.index]]" from an +// array-of-tables header), or "" if the line is not a table header. It strips a +// trailing inline comment so a commented header still matches by name, and keeps +// the full "[[...]]" form so an array-of-tables header is never treated as the +// same table as its "[...]" parent. func headerName(line string) string { if !tableHeaderRe.MatchString(line) { return "" } s := strings.TrimSpace(line) + // Array-of-tables: "[[name]]" — return through the closing "]]". + if strings.HasPrefix(s, "[[") { + if i := strings.Index(s, "]]"); i >= 0 { + return s[:i+2] + } + } if i := strings.Index(s, "]"); i >= 0 { return s[:i+1] } diff --git a/libs/localenv/merge_test.go b/libs/localenv/merge_test.go index b8b7602361..f748934165 100644 --- a/libs/localenv/merge_test.go +++ b/libs/localenv/merge_test.go @@ -398,10 +398,43 @@ func TestHeaderName(t *testing.T) { assert.Equal(t, "[project]", headerName("[project]")) assert.Equal(t, "[project]", headerName(" [project] # note")) assert.Equal(t, "[tool.uv]", headerName("[tool.uv]#x")) + // Array-of-tables headers are distinct from their parent table. + assert.Equal(t, "[[tool.uv.index]]", headerName("[[tool.uv.index]]")) + assert.Equal(t, "[[tool.uv.index]]", headerName(" [[tool.uv.index]] # note")) assert.Empty(t, headerName("requires-python = \"3.12\"")) assert.Empty(t, headerName("dev = [\"a\"]")) } +func TestMergeToolUvWithArrayOfTablesChild(t *testing.T) { + // A [tool.uv] table followed by its [[tool.uv.index]] array-of-tables child: + // the managed constraint block must attach to [tool.uv], not leak into the + // index item, and the result must be valid TOML. + in := []byte(`[project] +requires-python = ">=3.10" + +[dependency-groups] +dev = ["databricks-connect~=16.0.0"] + +[tool.uv] + +[[tool.uv.index]] +name = "internal" +url = "https://packages.example/simple" +`) + out, _, err := MergeManaged(in, testConstraints()) + require.NoError(t, err) + s := string(out) + requireValidTOML(t, out) + // The index array-of-tables is preserved intact. + assert.Contains(t, s, `[[tool.uv.index]]`) + assert.Contains(t, s, `name = "internal"`) + assert.Contains(t, s, "pydantic~=2.10.6") + // Merge-twice is byte-identical. + twice, _, err := MergeManaged(out, testConstraints()) + require.NoError(t, err) + assert.Equal(t, s, string(twice)) +} + func countOccurrences(s, substr string) int { return strings.Count(s, substr) }