Coverage for plugin/scripts/_rhiza_toml.py: 100%
58 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Edit a key in a TOML table without reformatting what the author wrote.
4The shared substrate under every script here that touches a `pyproject.toml` or a
5`Cargo.toml` — the three skeleton finishers, `set_license` and `set_python_version`. It
6exists because all five had grown their own copy of "find the `[project]` table": three
7near-identical `_table_block`/`_table_span`/`_project_block` functions, and a fourth copy
8of the trailing-newline dance in every editing function.
10**Text, not a parser.** `tomllib` is read-only in the stdlib, and a round-trip through
11any writer would reflow the comments and key order `uv` and `cargo` put there on
12purpose — turning a diff that should add three lines into one that rewrites the file.
13So every function here works on ``text.splitlines()`` and preserves everything it does
14not target, down to whether the file ended in a newline.
16Two operations cover most callers: :func:`merge_table` adds absent keys to a table
17(creating the table when that is allowed), and :func:`set_key` replaces one key whose
18current value is a recognised placeholder. Both leave a value the user wrote alone —
19that policy is the entire point of these scripts, and it lives here so none of them has
20to restate it. :func:`require_table` and :func:`table_end` are the lower-level pair for
21a caller that rewrites lines itself, as `set_license` does when it clears a key before
22reinserting it.
23"""
25from __future__ import annotations
27import re
28from collections.abc import Callable
30# A TOML bare key, plus the dots that make `[project.urls]`-style names. Deliberately
31# wider than any single caller needs: over-matching only makes "is this key already
32# here?" more accurate, while under-matching would add a duplicate key.
33_KEY = re.compile(r"^\s*([A-Za-z0-9_.-]+)\s*=")
36def table_span(lines: list[str], name: str) -> tuple[int, int] | None:
37 """Return ``(header_idx, end_idx)`` of the top-level ``[name]`` table, or None.
39 ``end_idx`` is the index of the next table header, or ``len(lines)`` when *name* is
40 the last table in the document — so ``lines[header + 1 : end]`` is always the body.
42 >>> lines = ["[project]", 'name = "demo"', "", "[tool.ruff]", "line-length = 100"]
43 >>> table_span(lines, "project")
44 (0, 3)
45 >>> lines[1:3]
46 ['name = "demo"', '']
47 >>> table_span(lines, "missing") is None
48 True
49 """
50 header = next((i for i, line in enumerate(lines) if line.strip() == f"[{name}]"), None)
51 if header is None:
52 return None
53 for i in range(header + 1, len(lines)):
54 if lines[i].lstrip().startswith("["):
55 return header, i
56 return header, len(lines)
59def require_table(lines: list[str], name: str, filename: str) -> tuple[int, int]:
60 """Return :func:`table_span` for *name*, raising ValueError when it is absent.
62 The message names *filename* because it is reported straight to the user: a bare
63 "no [package] table" leaves them guessing which file was read.
64 """
65 span = table_span(lines, name)
66 if span is None:
67 raise ValueError(f"{filename} has no [{name}] table")
68 return span
71def present_keys(lines: list[str], header: int, end: int) -> set[str]:
72 """Return the keys assigned in the table body bounded by *header* and *end*.
74 >>> sorted(present_keys(["[project]", 'name = "demo"', "version = '0.1.0'"], 0, 3))
75 ['name', 'version']
76 """
77 return {
78 match.group(1)
79 for line in lines[header + 1 : end]
80 if (match := _KEY.match(line)) is not None
81 }
84def table_end(lines: list[str], header: int, end: int) -> int:
85 """Return *end* moved back past the blank lines that pad the end of a table body.
87 The insertion point for a new key: inside the table, before whatever blank line
88 separates it from the next header. Inserting at a raw *end* would put the key after
89 that blank line — still inside the table as TOML reads it, but it looks detached.
91 >>> lines = ["[project]", 'name = "demo"', "", "[tool.ruff]"]
92 >>> table_end(lines, 0, 3)
93 2
94 """
95 while end > header + 1 and not lines[end - 1].strip():
96 end -= 1
97 return end
100def append_table(lines: list[str], header: str, body: list[str]) -> None:
101 """Append a ``[header]`` table with *body* to the end of the document, in place.
103 Trailing blank lines are collapsed first, so the new table is preceded by exactly one
104 blank line however many the file happened to end with.
105 """
106 while lines and not lines[-1].strip():
107 lines.pop()
108 lines.extend(["", header, *body])
111def rejoin(original: str, lines: list[str]) -> str:
112 r"""Join *lines*, restoring whether *original* ended in a newline.
114 Every editing function routes its return value through this. A manifest that did not
115 end in a newline must not grow one — the resulting one-line diff would be noise in
116 somebody else's PR.
118 >>> rejoin('name = "demo"\n', ["name = 'demo'", "version = '0.1.0'"])
119 "name = 'demo'\nversion = '0.1.0'\n"
120 >>> rejoin('name = "demo"', ["name = 'demo'", "version = '0.1.0'"])
121 "name = 'demo'\nversion = '0.1.0'"
122 """
123 text = "\n".join(lines)
124 return text + "\n" if original.endswith("\n") else text
127def merge_table(
128 text: str,
129 table: str,
130 wanted: dict[str, str],
131 *,
132 filename: str,
133 required: bool = False,
134) -> tuple[str, list[str]]:
135 r"""Add the keys of *wanted* that *table* does not already declare.
137 *wanted* maps a key to its **rendered** right-hand side — ``'"a string"'``, or
138 whatever ``json.dumps`` produced — because callers differ on how a value should be
139 written and none of that is this function's business.
141 Returns ``(new_text, added)``, where *added* lists the keys written and is empty when
142 the table already had them all. A key already present is never overwritten: it is the
143 user's, and the skeleton's job is to close gaps, not to impose values.
145 New keys go at the **end** of the table body rather than under the header, because
146 `cargo` and `uv` both put `name` and `version` first and readers expect to find them
147 there. With *required* set, an absent table raises ValueError instead of being
148 created — a `Cargo.toml` with no ``[package]`` is a virtual workspace, which is a
149 situation to report rather than a table to add.
151 The key already there is the user's and survives; only the absent one is written, at
152 the end of the body:
154 >>> text = '[package]\nname = "demo"\n'
155 >>> new, added = merge_table(
156 ... text, "package", {"name": '"other"', "edition": '"2021"'}, filename="Cargo.toml"
157 ... )
158 >>> added
159 ['edition']
160 >>> print(new, end="")
161 [package]
162 name = "demo"
163 edition = "2021"
164 """
165 lines = text.splitlines()
166 span = table_span(lines, table)
167 if span is None:
168 if required:
169 raise ValueError(f"{filename} has no [{table}] table")
170 append_table(lines, f"[{table}]", [f"{key} = {value}" for key, value in wanted.items()])
171 return rejoin(text, lines), list(wanted)
173 header, end = span
174 added = [key for key in wanted if key not in present_keys(lines, header, end)]
175 insert_at = table_end(lines, header, end)
176 lines[insert_at:insert_at] = [f"{key} = {wanted[key]}" for key in added]
177 return rejoin(text, lines), added
180def set_key(
181 text: str,
182 table: str,
183 key: str,
184 rendered: str,
185 *,
186 filename: str,
187 replaceable: Callable[[str], bool],
188) -> tuple[str, bool]:
189 r"""Set a single *key* in *table* to *rendered*; return ``(new_text, changed)``.
191 Unlike :func:`merge_table` this one *replaces* — but only when the value already
192 there is a placeholder the initialiser wrote, which is what *replaceable* decides
193 from the current right-hand side. Anything else is the user's and the text comes back
194 unchanged.
196 An absent key is inserted directly under the table header. That differs from
197 :func:`merge_table` on purpose: these are the keys the initialiser itself would have
198 written near the top (`description`, `authors`), so that is where a reader looks for
199 them.
201 Raises ValueError when *table* is absent — there would be nowhere to put the key.
203 >>> placeholder = lambda current: "Add your description" in current
204 >>> text = '[project]\nname = "demo"\ndescription = "Add your description here"\n'
205 >>> new, changed = set_key(
206 ... text, "project", "description", '"Drives rhiza"',
207 ... filename="pyproject.toml", replaceable=placeholder,
208 ... )
209 >>> changed
210 True
211 >>> print(new, end="")
212 [project]
213 name = "demo"
214 description = "Drives rhiza"
216 Run again, the value is no longer a placeholder — so it is the user's, and it stays:
218 >>> set_key(
219 ... new, "project", "description", '"Something else"',
220 ... filename="pyproject.toml", replaceable=placeholder,
221 ... )[1]
222 False
223 """
224 lines = text.splitlines()
225 header, end = require_table(lines, table, filename)
226 pattern = re.compile(rf"^\s*{re.escape(key)}\s*=\s*(.*)$")
227 new_line = f"{key} = {rendered}"
229 for i in range(header + 1, end):
230 match = pattern.match(lines[i])
231 if match is None:
232 continue
233 if not replaceable(match.group(1)):
234 return text, False
235 lines[i] = new_line
236 break
237 else:
238 lines.insert(header + 1, new_line)
240 return rejoin(text, lines), True