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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- 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 ""
|