Coverage for /builds/svesch/cikit/src/cikit/rst_tools.py: 94%

153 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-09 14:17 +0000

1# SPDX-License-Identifier: GPL-3.0-or-later 

2"""Reusable reStructuredText helpers for cikit. 

3 

4Author: 

5 Sven Schlender 

6Date: 

7 2026-05-29 

8""" 

9 

10from pathlib import Path 

11from dataclasses import dataclass, field 

12 

13from cikit.artifacts import Artifact, ArtifactType 

14from cikit.errors import CiToolsError 

15 

16 

17RST_HEADING_MARKERS = ("=", "-", "^", "~", '"') 

18 

19 

20class SphinxPaths: 

21 """Build stable RST links for generated Sphinx content. 

22 

23 The helper maps registered cikit artifacts to their location below the 

24 Sphinx static directory. 

25 

26 Args: 

27 generated_prefix: Relative link prefix from generated pages to the 

28 Sphinx static root. 

29 """ 

30 

31 def __init__(self, generated_prefix: str = "../_static"): 

32 """Initialize the RST link helper. 

33 

34 Args: 

35 generated_prefix: Relative link prefix from generated pages to the 

36 Sphinx static root. 

37 """ 

38 self.generated_prefix = generated_prefix.rstrip("/") 

39 

40 def rst_artifact(self, artifact: Artifact, *parts) -> str: 

41 """Build an RST link for a registered artifact. 

42 

43 Args: 

44 artifact: Registered artifact. 

45 *parts: Optional path components below the artifact. 

46 

47 Returns: 

48 Relative RST link path. 

49 """ 

50 if artifact.artifact_type == ArtifactType.BADGE: 

51 return self._build("badges", artifact.target_name, *parts) 

52 

53 return self._build("artifacts", artifact.target_name, *parts) 

54 

55 def artifact_path(self, artifact: Artifact, *parts) -> Path: 

56 """Return the path of an artifact below the Sphinx static directory. 

57 

58 Args: 

59 artifact: Registered artifact. 

60 *parts: Optional path components below the artifact. 

61 

62 Returns: 

63 Artifact path relative to the Sphinx static directory. 

64 """ 

65 directory = "badges" if artifact.artifact_type == ArtifactType.BADGE else "artifacts" 

66 return Path(directory, artifact.target_name, *parts) 

67 

68 def _build(self, *parts) -> str: 

69 """Build a normalized relative RST link path. 

70 

71 Args: 

72 *parts: Path components below the Sphinx static root. 

73 

74 Returns: 

75 Normalized relative RST link path. 

76 """ 

77 clean_parts = [str(part).strip("/") for part in parts if str(part)] 

78 suffix = "/".join(clean_parts) 

79 

80 if not suffix: 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true

81 return self.generated_prefix 

82 

83 return f"{self.generated_prefix}/{suffix}" 

84 

85 

86def get_sphinx_paths() -> SphinxPaths: 

87 """Create an RST link helper for generated Sphinx content. 

88 

89 Returns: 

90 Configured Sphinx paths instance. 

91 """ 

92 return SphinxPaths() 

93 

94 

95@dataclass 

96class RstBlock: 

97 """Represent a reusable RST block. 

98 

99 Args: 

100 title: Optional block title. 

101 lines: RST lines contained in this block. 

102 blocks: Child blocks rendered below this block. 

103 """ 

104 title: str = None 

105 lines: list = field(default_factory=list) 

106 blocks: list = field(default_factory=list) 

107 

108 def append(self, line: str = ""): 

109 """Append one line to the block. 

110 

111 Args: 

112 line: Line to append. 

113 """ 

114 self.lines.append(line) 

115 

116 def extend(self, lines: list): 

117 """Append multiple lines to the block. 

118 

119 Args: 

120 lines: Lines to append. 

121 """ 

122 self.lines.extend(lines) 

123 

124 def add_block(self, block: "RstBlock"): 

125 """Add a child block. 

126 

127 Args: 

128 block: Child block to append. 

129 """ 

130 self.blocks.append(block) 

131 

132 def render(self, depth: int) -> list: 

133 """Render this block and all child blocks. 

134 

135 Args: 

136 depth: Heading depth derived from the block location. 

137 

138 Returns: 

139 Rendered RST lines. 

140 

141 Raises: 

142 CiToolsError: If the heading depth is unsupported. 

143 """ 

144 lines = [] 

145 

146 if self.title: 

147 lines.extend(_heading(self.title, depth)) 

148 

149 lines.extend(self.lines) 

150 if lines and lines[-1] != "": 

151 lines.append("") 

152 

153 for block in self.blocks: 

154 block_lines = block.render(depth + 1) 

155 if not block_lines: 155 ↛ 156line 155 didn't jump to line 156 because the condition on line 155 was never true

156 continue 

157 

158 lines.extend(block_lines) 

159 if lines[-1] != "": 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true

160 lines.append("") 

161 

162 return lines 

163 

164 

165@dataclass 

166class RstDocument: 

167 """Represent a composable RST document. 

168 

169 The document owns the top-level title, optional table of contents 

170 configuration and a list of nested RST blocks. Heading levels are 

171 derived automatically from the block hierarchy. 

172 

173 Args: 

174 title: Document title. 

175 toc_depth: Local table-of-contents depth. Use 0 to disable it. 

176 lines: RST lines directly below the document title. 

177 blocks: Child blocks rendered below the document title. 

178 """ 

179 title: str 

180 toc_depth: int = 0 

181 lines: list = field(default_factory=list) 

182 blocks: list = field(default_factory=list) 

183 

184 def append(self, line: str = ""): 

185 """Append one line directly below the document title. 

186 

187 Args: 

188 line: Line to append. 

189 """ 

190 self.lines.append(line) 

191 

192 def extend(self, lines: list): 

193 """Append multiple lines directly below the document title. 

194 

195 Args: 

196 lines: Lines to append. 

197 """ 

198 self.lines.extend(lines) 

199 

200 def add_block(self, block: RstBlock): 

201 """Add a top-level block. 

202 

203 Args: 

204 block: Block to append below the document title. 

205 """ 

206 self.blocks.append(block) 

207 

208 def render(self) -> list: 

209 """Render the full document. 

210 

211 Returns: 

212 Rendered RST lines. 

213 

214 Raises: 

215 CiToolsError: If the heading depth is unsupported. 

216 """ 

217 lines = _heading(self.title, 0) 

218 

219 lines.extend(self.lines) 

220 if lines and lines[-1] != "": 

221 lines.append("") 

222 

223 if self.toc_depth > 0: 

224 lines.extend([".. contents::", " :local:", f" :depth: {self.toc_depth}", ""]) 

225 

226 for block in self.blocks: 

227 block_lines = block.render(1) 

228 if not block_lines: 

229 continue 

230 

231 lines.extend(block_lines) 

232 if lines[-1] != "": 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true

233 lines.append("") 

234 

235 return lines 

236 

237 def text(self) -> str: 

238 """Render the full document as text. 

239 

240 Returns: 

241 Rendered RST text. 

242 

243 Raises: 

244 CiToolsError: If the heading depth is unsupported. 

245 """ 

246 return "\n".join(self.render()) 

247 

248 

249def timeline_artifacts_block(artifacts: list, *, title: str = "Timeline") -> RstBlock: 

250 """Create an RST block for timeline chart artifacts. 

251 

252 Args: 

253 artifacts: Timeline artifact entries returned by cikit data. 

254 title: Block title. 

255 

256 Returns: 

257 RST block containing interactive timeline chart directives. 

258 """ 

259 if not artifacts: 

260 return RstBlock() 

261 

262 block = RstBlock(title) 

263 paths = get_sphinx_paths() 

264 

265 for artifact in artifacts: 

266 block.extend([f".. cikit-chart:: {paths.rst_artifact(artifact)}", " :width: 100%", ""]) 

267 

268 return block 

269 

270 

271def _heading(title: str, depth: int) -> list: 

272 """Return an RST heading for the given tree depth. 

273 

274 Args: 

275 title: Heading text. 

276 depth: Tree depth, where 0 is the document title. 

277 

278 Returns: 

279 RST heading lines. 

280 

281 Raises: 

282 CiToolsError: If the requested heading depth is unsupported. 

283 """ 

284 if depth < 0 or depth >= len(RST_HEADING_MARKERS): 

285 raise CiToolsError(f"Maximum RST heading depth exceeded ({depth}).") 

286 

287 marker = RST_HEADING_MARKERS[depth] 

288 return [title, marker * len(title), ""] 

289 

290 

291def _format_bytes(value: int) -> str: 

292 """Format a byte count using a suitable binary unit. 

293 

294 Args: 

295 value: Size in bytes. 

296 

297 Returns: 

298 Human-readable size using B, KiB, MiB, GiB, or TiB. 

299 """ 

300 units = ("B", "KiB", "MiB", "GiB", "TiB") 

301 size = float(value) 

302 

303 for unit in units[:-1]: 303 ↛ 308line 303 didn't jump to line 308 because the loop on line 303 didn't complete

304 if size < 1024: 

305 break 

306 size /= 1024 

307 else: 

308 unit = units[-1] 

309 

310 if unit == "B": 

311 return f"{value} B" 

312 

313 return f"{size:.1f} {unit}" 

314 

315 

316def _format_artifact_size(size: int) -> str: 

317 """Format an artifact size for report output. 

318 

319 Args: 

320 size: Artifact size in bytes. 

321 

322 Returns: 

323 Human-readable artifact size or an empty string if no size is available. 

324 """ 

325 if size is None: 

326 return "" 

327 

328 units = ("B", "KiB", "MiB", "GiB") 

329 value = float(size) 

330 

331 for unit in units[:-1]: 331 ↛ 336line 331 didn't jump to line 336 because the loop on line 331 didn't complete

332 if value < 1024: 

333 break 

334 value /= 1024 

335 else: 

336 unit = units[-1] 

337 

338 if unit == "B": 

339 return f"{int(value)} {unit}" 

340 

341 return f"{value:.1f} {unit}" 

342 

343 

344def memory_usage_block(data: dict, *, title: str = "Memory Usage") -> RstBlock: 

345 """Create an RST table containing detected memory regions. 

346 

347 Memory regions are discovered from flat cikit data keys following the 

348 ``memory_<region>_used``, ``memory_<region>_total`` and 

349 ``memory_<region>_percent`` naming convention. Incomplete regions are 

350 ignored. 

351 

352 Args: 

353 data: Run data containing memory metrics. 

354 title: Block title. 

355 

356 Returns: 

357 RST block containing the memory usage table. The block is empty if no 

358 complete memory regions are available. 

359 """ 

360 regions = [] 

361 

362 for key in data: 

363 if not key.startswith("memory_") or not key.endswith("_used"): 

364 continue 

365 

366 region = key[len("memory_"):-len("_used")] 

367 total_key = f"memory_{region}_total" 

368 percent_key = f"memory_{region}_percent" 

369 

370 if total_key not in data or percent_key not in data: 

371 continue 

372 

373 regions.append((region, data[key], data[total_key], data[percent_key])) 

374 

375 if not regions: 

376 return RstBlock() 

377 

378 block = RstBlock(title) 

379 block.extend([ 

380 ".. list-table::", 

381 " :header-rows: 1", 

382 "", 

383 " * - Region", 

384 " - Used", 

385 " - Total", 

386 " - Usage", 

387 ]) 

388 

389 for region, used, total, percent in sorted(regions): 

390 block.extend([ 

391 f" * - {region.replace('_', ' ').upper()}", 

392 f" - {_format_bytes(used)}", 

393 f" - {_format_bytes(total)}", 

394 f" - {percent:.1f} %", 

395 ]) 

396 

397 return block 

398 

399 

400def log_artifacts_block(artifacts: list, *, title: str = "Logs") -> RstBlock: 

401 """Create an RST block containing collapsible log artifacts. 

402 

403 Args: 

404 artifacts: Log artifact entries returned by cikit data. 

405 title: Block title. 

406 

407 Returns: 

408 RST block containing cikit log directives. 

409 """ 

410 if not artifacts: 

411 return RstBlock() 

412 

413 block = RstBlock(title) 

414 paths = get_sphinx_paths() 

415 

416 for artifact in artifacts: 

417 block.extend([f".. cikit-log:: {paths.rst_artifact(artifact)}", ""]) 

418 

419 return block 

420 

421 

422def artifact_links_block(artifacts: list, *, title: str = "Build Artifacts") -> RstBlock: 

423 """Create an RST table containing downloadable artifact metadata. 

424 

425 Each artifact is rendered with a download link, human-readable file size, 

426 and SHA-256 hash. Missing size or hash metadata is represented by an empty 

427 table cell. 

428 

429 Args: 

430 artifacts: Registered artifacts to render. 

431 title: Block title. 

432 

433 Returns: 

434 RST block containing the artifact table. 

435 """ 

436 if not artifacts: 

437 return RstBlock() 

438 

439 block = RstBlock(title) 

440 paths = get_sphinx_paths() 

441 

442 block.extend([ 

443 ".. list-table::", 

444 " :header-rows: 1", 

445 " :widths: 40 15 45", 

446 "", 

447 " * - Artifact", 

448 " - Size", 

449 " - SHA-256", 

450 ]) 

451 

452 for artifact in artifacts: 

453 download = f":download:`{artifact.target_name} <{paths.rst_artifact(artifact)}>`" 

454 size = _format_artifact_size(artifact.size) 

455 artifact_hash = f":cikit-hash:`{artifact.hash}`" if artifact.hash else "" 

456 

457 block.extend([ 

458 f" * - {download}", 

459 f" - {size}", 

460 f" - {artifact_hash}", 

461 ]) 

462 

463 return block