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

1#!/usr/bin/env python3 

2"""Edit a key in a TOML table without reformatting what the author wrote. 

3 

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. 

9 

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. 

15 

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""" 

24 

25from __future__ import annotations 

26 

27import re 

28from collections.abc import Callable 

29 

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*=") 

34 

35 

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. 

38 

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. 

41 

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) 

57 

58 

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. 

61 

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 

69 

70 

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*. 

73 

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 } 

82 

83 

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. 

86 

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. 

90 

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 

98 

99 

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. 

102 

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]) 

109 

110 

111def rejoin(original: str, lines: list[str]) -> str: 

112 r"""Join *lines*, restoring whether *original* ended in a newline. 

113 

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. 

117 

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 

125 

126 

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. 

136 

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. 

140 

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. 

144 

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. 

150 

151 The key already there is the user's and survives; only the absent one is written, at 

152 the end of the body: 

153 

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) 

172 

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 

178 

179 

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)``. 

190 

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. 

195 

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. 

200 

201 Raises ValueError when *table* is absent — there would be nowhere to put the key. 

202 

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" 

215 

216 Run again, the value is no longer a placeholder — so it is the user's, and it stays: 

217 

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}" 

228 

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) 

239 

240 return rejoin(text, lines), True