syncade 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. syncade-0.6.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,410 @@
1
+ """Minimal TOML *writer* for ``.syncade/config.toml`` (pr-v2-30).
2
+
3
+ stdlib ``tomllib`` reads TOML but cannot write it, and syncade adds no runtime dependency. This
4
+ writes the subset syncade produces — nested tables, arrays-of-tables, inline scalar arrays, and
5
+ str/int/float/bool scalars — and is round-trip-correct (``tomllib.loads(dumps(d)) == d``), proven
6
+ in tests. ``None`` is omitted (TOML has no null); an unserializable value raises ``ValueError``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import tomllib
13
+
14
+ _BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
15
+
16
+
17
+ def dumps(data: dict) -> str:
18
+ lines: list[str] = []
19
+ _emit(data, (), lines)
20
+ text = "\n".join(lines).strip("\n")
21
+ return text + "\n" if text else ""
22
+
23
+
24
+ def _emit(table: dict, path: tuple[str, ...], lines: list[str]) -> None:
25
+ # A table's own scalars MUST precede its sub-tables in TOML, so emit them in two passes.
26
+ for key, value in table.items():
27
+ if value is None:
28
+ continue
29
+ if isinstance(value, list) and not (value and isinstance(value[0], dict)):
30
+ # a scalar array is an INLINE value of this table — emitting it in the second pass
31
+ # would place it after a sub-table header, silently reparenting it into that sub-table
32
+ lines.append(f"{_fmt_key(key)} = [{', '.join(_fmt_scalar(v) for v in value)}]")
33
+ elif not isinstance(value, (dict, list)):
34
+ lines.append(f"{_fmt_key(key)} = {_fmt_scalar(value)}")
35
+ for key, value in table.items():
36
+ if isinstance(value, dict):
37
+ lines.append("")
38
+ lines.append(f"[{_fmt_path(path + (key,))}]")
39
+ _emit(value, path + (key,), lines)
40
+ elif isinstance(value, list):
41
+ if value and isinstance(value[0], dict):
42
+ # array-of-tables ([[key]]) — each element must be a dict
43
+ for item in value:
44
+ if not isinstance(item, dict):
45
+ raise ValueError(f"cannot serialize a mixed list at {key!r}")
46
+ lines.append("")
47
+ lines.append(f"[[{_fmt_path(path + (key,))}]]")
48
+ _emit(item, path + (key,), lines)
49
+
50
+
51
+ def _fmt_path(path: tuple[str, ...]) -> str:
52
+ return ".".join(_fmt_key(p) for p in path)
53
+
54
+
55
+ def _fmt_key(key: str) -> str:
56
+ return key if _BARE_KEY.match(key) else _fmt_scalar(key)
57
+
58
+
59
+ def _fmt_scalar(value) -> str:
60
+ if isinstance(value, bool):
61
+ return "true" if value else "false"
62
+ if isinstance(value, int):
63
+ return str(value)
64
+ if isinstance(value, float):
65
+ return repr(value)
66
+ if isinstance(value, str):
67
+ escaped = (
68
+ value.replace("\\", "\\\\")
69
+ .replace('"', '\\"')
70
+ .replace("\b", "\\b")
71
+ .replace("\t", "\\t")
72
+ .replace("\n", "\\n")
73
+ .replace("\f", "\\f")
74
+ .replace("\r", "\\r")
75
+ )
76
+ # TOML forbids U+0000-U+0008, U+000B, U+000E-U+001F, U+007F raw in basic strings
77
+ escaped = re.sub(
78
+ r"[\x00-\x07\x0b\x0e-\x1f\x7f]", lambda m: f"\\u{ord(m.group()):04X}", escaped
79
+ )
80
+ return f'"{escaped}"'
81
+ raise ValueError(f"cannot serialize value of type {type(value).__name__}: {value!r}")
82
+
83
+
84
+ def _toml_equal(a, b) -> bool:
85
+ """Type-strict TOML value equality: bool and int are distinct even though ``True == 1``."""
86
+ if type(a) is not type(b):
87
+ return False
88
+ if isinstance(a, dict):
89
+ return a.keys() == b.keys() and all(_toml_equal(a[k], b[k]) for k in a)
90
+ if isinstance(a, list):
91
+ return len(a) == len(b) and all(_toml_equal(x, y) for x, y in zip(a, b, strict=True))
92
+ return a == b
93
+
94
+
95
+ def render(data: dict, old_text: str) -> str:
96
+ """The text to write for ``data``, preserving ``old_text``'s comments/formatting where possible.
97
+
98
+ A config file is meant to be hand-edited, so regenerating it from parsed data (:func:`dumps`)
99
+ silently destroys the operator's comments, key order, and layout. This instead patches only the
100
+ lines whose values changed — and keeps that patch ONLY if it re-parses to ``data`` after
101
+ normalizing away ``None`` leaves and empty tables (which TOML cannot represent).
102
+ Anything the patcher cannot place safely falls back to :func:`dumps`, so the worst case is the
103
+ old behaviour: a write can lose comments, but can never write a value that wasn't intended.
104
+ """
105
+ if not old_text.strip():
106
+ return dumps(data)
107
+ # `dumps` omits None (TOML has no null), so the FILE's data is `data` minus its None leaves.
108
+ # Patch and verify against that: otherwise any None — every materialized optional field, and the
109
+ # documented "clear an optional" gesture — makes the check unsatisfiable and silently forces the
110
+ # comment-destroying rewrite. (A key whose new value is None correctly becomes a removal.)
111
+ wanted = _strip_none(data)
112
+ try:
113
+ patched = _patch(old_text, wanted)
114
+ # Strip empty tables from both sides: a header-only [section] in old_text parses to {}
115
+ # but _strip_none(data) already dropped it, so a naive compare would always mismatch and
116
+ # fall back to the comment-destroying dumps(). Stripping both sides aligns them on content.
117
+ if patched is not None and _toml_equal(_strip_none(tomllib.loads(patched)), wanted):
118
+ return patched
119
+ except (tomllib.TOMLDecodeError, ValueError, KeyError, IndexError, TypeError):
120
+ pass # any patcher failure degrades to the full rewrite, never to a wrong file
121
+ return dumps(data)
122
+
123
+
124
+ def _patch(old_text: str, new_data: dict) -> str | None:
125
+ """``old_text`` with only the CHANGED/ADDED/REMOVED leaves applied, or None when a delta cannot
126
+ be placed safely (the caller then falls back to a full rewrite)."""
127
+ old_data = tomllib.loads(old_text)
128
+ if _toml_equal(old_data, new_data):
129
+ return old_text # a no-op write must not reformat the file
130
+ old_flat, new_flat = _flatten(old_data), _flatten(new_data)
131
+ changed = [p for p in new_flat if p in old_flat and not _toml_equal(old_flat[p], new_flat[p])]
132
+ added = [p for p in new_flat if p not in old_flat]
133
+ removed = [p for p in old_flat if p not in new_flat]
134
+ # Work on ENDING-LESS text lines with a PARALLEL per-line ending list, so each existing line
135
+ # keeps its own ending (mixed files stay mixed) while a file with no trailing newline can never
136
+ # leave a bare CR/LF at a boundary. Inserted lines take the dominant ending.
137
+ lines, endings = _split_lines(old_text)
138
+ had_final_nl = bool(lines) and endings[-1] != ""
139
+ dominant = "\r\n" if "\r\n" in old_text else "\n"
140
+ key_at, table_last, table_header = _scan(lines)
141
+
142
+ for path in changed: # replace the value in place, keeping key text + trailing comment
143
+ line_no = key_at.get(path)
144
+ if line_no is None:
145
+ return None
146
+ replaced = _replace_value(lines[line_no], new_flat[path])
147
+ if replaced is None:
148
+ return None
149
+ lines[line_no] = replaced
150
+
151
+ drop = set()
152
+ for path in removed:
153
+ line_no = key_at.get(path)
154
+ if line_no is None:
155
+ return None
156
+ drop.add(line_no)
157
+
158
+ after: dict[int, list[str]] = {} # insert AFTER this line
159
+ before: dict[int, list[str]] = {} # insert BEFORE this line (root keys precede any table)
160
+ tail: list[str] = [] # appended blocks (a leading "" is a blank separator line)
161
+ by_table: dict[tuple[str, ...], list[tuple[str, ...]]] = {}
162
+ for path in added:
163
+ by_table.setdefault(path[:-1], []).append(path)
164
+ aot_new: dict[tuple[str, ...], list[tuple[int, list[str]]]] = {}
165
+ for table, paths in by_table.items():
166
+ rendered = [f"{_fmt_key(p[-1])} = {_render_value(new_flat[p])}" for p in paths]
167
+ anchor = table_last.get(table, table_header.get(table))
168
+ if anchor is not None: # the table exists — append to its key block
169
+ after.setdefault(anchor, []).extend(rendered)
170
+ elif table == (): # a root key must precede every table header
171
+ first_header = next(
172
+ (i for i, ln in enumerate(lines) if ln.lstrip().startswith("[")), None
173
+ )
174
+ if first_header is None:
175
+ tail.extend(rendered)
176
+ else:
177
+ before.setdefault(first_header, []).extend(rendered)
178
+ elif any(part.isdigit() for part in table):
179
+ # New AOT element: accumulate by base array name for ordered tail emission.
180
+ ridx = max(i for i, p in enumerate(table) if p.isdigit())
181
+ aot_new.setdefault(table[:ridx], []).append((int(table[ridx]), rendered))
182
+ else: # a brand-new table — append a block, leaving existing text untouched
183
+ tail.extend(["", f"[{_fmt_path(table)}]", *rendered])
184
+ for base, elements in aot_new.items():
185
+ for _, elem_rendered in sorted(elements):
186
+ tail.extend(["", f"[[{_fmt_path(base)}]]", *elem_rendered])
187
+
188
+ out: list[tuple[str, str]] = [] # (text, ending); inserted lines take the dominant ending
189
+ for i, line in enumerate(lines):
190
+ out.extend((nl, dominant) for nl in before.get(i, []))
191
+ if i not in drop:
192
+ out.append((line, endings[i]))
193
+ out.extend((nl, dominant) for nl in after.get(i, []))
194
+ if tail and out and out[-1][0] == "" and tail[0] == "":
195
+ tail = tail[1:] # file ends in a blank line; a new block starts with one — don't double it
196
+ out.extend((nl, dominant) for nl in tail)
197
+ if not out:
198
+ return ""
199
+ # Every line but the last carries a real ending (a mid-file "" — the original trailing-less line
200
+ # now followed by appends — takes the dominant ending). The final line keeps its OWN ending, but
201
+ # the file's trailing-newline PROPERTY is byte-preserved: dropped entirely if the original had
202
+ # one, so an appended (dominant-ended) final line loses its newline to match.
203
+ body = "".join(text + (ending or dominant) for text, ending in out[:-1])
204
+ last_text, last_ending = out[-1]
205
+ return body + last_text + (last_ending if had_final_nl else "")
206
+
207
+
208
+ def _strip_none(data: dict) -> dict:
209
+ """``data`` without its ``None`` leaves or empty sub-tables — what the written file contains."""
210
+ out: dict = {}
211
+ for key, value in data.items():
212
+ if value is None:
213
+ continue
214
+ if isinstance(value, dict):
215
+ stripped = _strip_none(value)
216
+ if stripped: # drop tables that become empty after stripping None leaves
217
+ out[key] = stripped
218
+ elif isinstance(value, list):
219
+ out[key] = [_strip_none(i) if isinstance(i, dict) else i for i in value]
220
+ else:
221
+ out[key] = value
222
+ return out
223
+
224
+
225
+ def _split_lines(text: str) -> tuple[list[str], list[str]]:
226
+ """Ending-less logical lines + a PARALLEL list of each line's ending (CRLF, LF, or
227
+ ``""`` for a final line with no newline). Only ``\\n`` / ``\\r\\n`` are breaks (unlike
228
+ :meth:`str.splitlines`, which also breaks on other C0 controls a TOML value can hold)."""
229
+ lines: list[str] = []
230
+ endings: list[str] = []
231
+ start = i = 0
232
+ n = len(text)
233
+ while i < n:
234
+ if text[i] == "\r" and i + 1 < n and text[i + 1] == "\n":
235
+ lines.append(text[start:i])
236
+ endings.append("\r\n")
237
+ i += 2
238
+ start = i
239
+ elif text[i] == "\n":
240
+ lines.append(text[start:i])
241
+ endings.append("\n")
242
+ i += 1
243
+ start = i
244
+ else:
245
+ i += 1
246
+ if start < n or not lines: # a trailing chunk with no newline is the final (ending-less) line
247
+ lines.append(text[start:])
248
+ endings.append("")
249
+ return lines, endings
250
+
251
+
252
+ def _flatten(data: dict, prefix: tuple[str, ...] = ()) -> dict[tuple[str, ...], object]:
253
+ """Every scalar leaf as ``dotted-path -> value``. A list of tables recurses (indexed); a scalar
254
+ array (including an empty one) is itself a leaf."""
255
+ out: dict[tuple[str, ...], object] = {}
256
+ for key, value in data.items():
257
+ path = prefix + (key,)
258
+ if isinstance(value, dict):
259
+ out.update(_flatten(value, path))
260
+ elif isinstance(value, list) and value and all(isinstance(i, dict) for i in value):
261
+ for i, item in enumerate(value):
262
+ out.update(_flatten(item, path + (str(i),)))
263
+ else:
264
+ out[path] = value
265
+ return out
266
+
267
+
268
+ def _scan(lines: list[str]) -> tuple[dict[tuple[str, ...], int], dict[tuple[str, ...], int]]:
269
+ """Map each ``key = value`` line to its full dotted path, tracking `[table]` / `[[array]]`
270
+ headers (arrays get a per-path element index, matching :func:`_flatten`)."""
271
+ key_at: dict[tuple[str, ...], int] = {}
272
+ table_last: dict[tuple[str, ...], int] = {}
273
+ table_header: dict[tuple[str, ...], int] = {}
274
+ table: tuple[str, ...] = ()
275
+ seen_aot: dict[tuple[str, ...], int] = {}
276
+ for i, raw in enumerate(lines):
277
+ line = raw.strip()
278
+ if not line or line.startswith("#"):
279
+ continue
280
+ if line.startswith("[["):
281
+ end = line.find("]]")
282
+ if end < 0:
283
+ continue
284
+ base = _key_path(line[2:end].strip())
285
+ index = seen_aot.get(base, 0)
286
+ seen_aot[base] = index + 1
287
+ table = base + (str(index),)
288
+ table_header.setdefault(table, i)
289
+ continue
290
+ if line.startswith("["):
291
+ end = line.find("]")
292
+ if end < 0:
293
+ continue
294
+ table = _key_path(line[1:end].strip())
295
+ table_header.setdefault(table, i)
296
+ continue
297
+ eq = _eq_index(raw)
298
+ if eq is None:
299
+ continue
300
+ key_at[table + _key_path(raw[:eq].strip())] = i
301
+ table_last[table] = i
302
+ return key_at, table_last, table_header
303
+
304
+
305
+ def _key_path(text: str) -> tuple[str, ...]:
306
+ """Split a (possibly dotted, possibly quoted) key or table name into its path parts."""
307
+ parts: list[str] = []
308
+ current = ""
309
+ quote = ""
310
+ for ch in text:
311
+ if quote:
312
+ current += ch
313
+ if ch == quote:
314
+ quote = ""
315
+ elif ch in "\"'":
316
+ quote = ch
317
+ current += ch
318
+ elif ch == ".":
319
+ parts.append(_unquote(current.strip()))
320
+ current = ""
321
+ else:
322
+ current += ch
323
+ parts.append(_unquote(current.strip()))
324
+ return tuple(parts)
325
+
326
+
327
+ def _unquote(text: str) -> str:
328
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'":
329
+ return text[1:-1]
330
+ return text
331
+
332
+
333
+ def _eq_index(line: str) -> int | None:
334
+ """Index of the ``=`` separating key from value, ignoring any inside a quoted key."""
335
+ quote = ""
336
+ escaped = False
337
+ for i, ch in enumerate(line):
338
+ if quote:
339
+ if escaped:
340
+ escaped = False
341
+ elif ch == "\\" and quote == '"': # only a BASIC string honours backslash escapes
342
+ escaped = True
343
+ elif ch == quote:
344
+ quote = ""
345
+ elif ch in "\"'":
346
+ quote = ch
347
+ elif ch == "=":
348
+ return i
349
+ elif ch == "#":
350
+ return None
351
+ return None
352
+
353
+
354
+ def _replace_value(line: str, value) -> str | None:
355
+ """``line`` (no line ending) with its value replaced, keeping the key text, the operator's exact
356
+ spacing around ``=``, and the trailing comment at its original column."""
357
+ eq = _eq_index(line)
358
+ if eq is None:
359
+ return None
360
+ rest = line[eq + 1 :]
361
+ rendered = _render_value(value)
362
+ if rendered is None:
363
+ return None
364
+ comment = _trailing_comment(rest)
365
+ # Preserve the EXACT whitespace the operator put between `=` and the value (byte-perfect: never
366
+ # collapse `= 3` to `= 3`, never pad `=3` to `= 3`).
367
+ lead_ws = rest[: len(rest) - len(rest.lstrip(" \t"))]
368
+ prefix = f"{line[: eq + 1]}{lead_ws}{rendered}"
369
+ if not comment:
370
+ trail_ws = rest[len(rest.rstrip(" \t")) :] # keep any whitespace that trailed the value
371
+ return f"{prefix}{trail_ws}"
372
+ # Preserve the COLUMN of # by padding to it, not by copying the old whitespace run (which was
373
+ # sized for the old value; a different-width replacement would otherwise shift the #).
374
+ comment_text = comment.lstrip()
375
+ hash_col = len(line) - len(comment_text)
376
+ pad = max(1, hash_col - len(prefix))
377
+ return f"{prefix}{' ' * pad}{comment_text}"
378
+
379
+
380
+ def _render_value(value) -> str | None:
381
+ """A scalar or inline scalar-array as TOML text; None for anything not representable inline."""
382
+ if isinstance(value, list):
383
+ if any(isinstance(v, (dict, list)) for v in value):
384
+ return None
385
+ return f"[{', '.join(_fmt_scalar(v) for v in value)}]"
386
+ return _fmt_scalar(value)
387
+
388
+
389
+ def _trailing_comment(rest: str) -> str:
390
+ """A value's trailing `` # …``, including the whitespace that preceded the ``#``. "" when
391
+ the value carries no comment. Quotes are respected so a ``#`` inside a string is not mistaken
392
+ for a comment. The caller strips the whitespace and recomputes padding from the ``#`` column."""
393
+ quote = ""
394
+ escaped = False
395
+ for i, ch in enumerate(rest):
396
+ if quote:
397
+ if escaped:
398
+ escaped = False
399
+ elif ch == "\\" and quote == '"': # only a BASIC string honours backslash escapes
400
+ escaped = True
401
+ elif ch == quote:
402
+ quote = ""
403
+ elif ch in "\"'":
404
+ quote = ch
405
+ elif ch == "#":
406
+ start = i
407
+ while start > 0 and rest[start - 1] in " \t":
408
+ start -= 1
409
+ return rest[start:] if start < i else " " + rest[i:]
410
+ return ""