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
« 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.
4Author:
5 Sven Schlender
6Date:
7 2026-05-29
8"""
10from pathlib import Path
11from dataclasses import dataclass, field
13from cikit.artifacts import Artifact, ArtifactType
14from cikit.errors import CiToolsError
17RST_HEADING_MARKERS = ("=", "-", "^", "~", '"')
20class SphinxPaths:
21 """Build stable RST links for generated Sphinx content.
23 The helper maps registered cikit artifacts to their location below the
24 Sphinx static directory.
26 Args:
27 generated_prefix: Relative link prefix from generated pages to the
28 Sphinx static root.
29 """
31 def __init__(self, generated_prefix: str = "../_static"):
32 """Initialize the RST link helper.
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("/")
40 def rst_artifact(self, artifact: Artifact, *parts) -> str:
41 """Build an RST link for a registered artifact.
43 Args:
44 artifact: Registered artifact.
45 *parts: Optional path components below the artifact.
47 Returns:
48 Relative RST link path.
49 """
50 if artifact.artifact_type == ArtifactType.BADGE:
51 return self._build("badges", artifact.target_name, *parts)
53 return self._build("artifacts", artifact.target_name, *parts)
55 def artifact_path(self, artifact: Artifact, *parts) -> Path:
56 """Return the path of an artifact below the Sphinx static directory.
58 Args:
59 artifact: Registered artifact.
60 *parts: Optional path components below the artifact.
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)
68 def _build(self, *parts) -> str:
69 """Build a normalized relative RST link path.
71 Args:
72 *parts: Path components below the Sphinx static root.
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)
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
83 return f"{self.generated_prefix}/{suffix}"
86def get_sphinx_paths() -> SphinxPaths:
87 """Create an RST link helper for generated Sphinx content.
89 Returns:
90 Configured Sphinx paths instance.
91 """
92 return SphinxPaths()
95@dataclass
96class RstBlock:
97 """Represent a reusable RST block.
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)
108 def append(self, line: str = ""):
109 """Append one line to the block.
111 Args:
112 line: Line to append.
113 """
114 self.lines.append(line)
116 def extend(self, lines: list):
117 """Append multiple lines to the block.
119 Args:
120 lines: Lines to append.
121 """
122 self.lines.extend(lines)
124 def add_block(self, block: "RstBlock"):
125 """Add a child block.
127 Args:
128 block: Child block to append.
129 """
130 self.blocks.append(block)
132 def render(self, depth: int) -> list:
133 """Render this block and all child blocks.
135 Args:
136 depth: Heading depth derived from the block location.
138 Returns:
139 Rendered RST lines.
141 Raises:
142 CiToolsError: If the heading depth is unsupported.
143 """
144 lines = []
146 if self.title:
147 lines.extend(_heading(self.title, depth))
149 lines.extend(self.lines)
150 if lines and lines[-1] != "":
151 lines.append("")
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
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("")
162 return lines
165@dataclass
166class RstDocument:
167 """Represent a composable RST document.
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.
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)
184 def append(self, line: str = ""):
185 """Append one line directly below the document title.
187 Args:
188 line: Line to append.
189 """
190 self.lines.append(line)
192 def extend(self, lines: list):
193 """Append multiple lines directly below the document title.
195 Args:
196 lines: Lines to append.
197 """
198 self.lines.extend(lines)
200 def add_block(self, block: RstBlock):
201 """Add a top-level block.
203 Args:
204 block: Block to append below the document title.
205 """
206 self.blocks.append(block)
208 def render(self) -> list:
209 """Render the full document.
211 Returns:
212 Rendered RST lines.
214 Raises:
215 CiToolsError: If the heading depth is unsupported.
216 """
217 lines = _heading(self.title, 0)
219 lines.extend(self.lines)
220 if lines and lines[-1] != "":
221 lines.append("")
223 if self.toc_depth > 0:
224 lines.extend([".. contents::", " :local:", f" :depth: {self.toc_depth}", ""])
226 for block in self.blocks:
227 block_lines = block.render(1)
228 if not block_lines:
229 continue
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("")
235 return lines
237 def text(self) -> str:
238 """Render the full document as text.
240 Returns:
241 Rendered RST text.
243 Raises:
244 CiToolsError: If the heading depth is unsupported.
245 """
246 return "\n".join(self.render())
249def timeline_artifacts_block(artifacts: list, *, title: str = "Timeline") -> RstBlock:
250 """Create an RST block for timeline chart artifacts.
252 Args:
253 artifacts: Timeline artifact entries returned by cikit data.
254 title: Block title.
256 Returns:
257 RST block containing interactive timeline chart directives.
258 """
259 if not artifacts:
260 return RstBlock()
262 block = RstBlock(title)
263 paths = get_sphinx_paths()
265 for artifact in artifacts:
266 block.extend([f".. cikit-chart:: {paths.rst_artifact(artifact)}", " :width: 100%", ""])
268 return block
271def _heading(title: str, depth: int) -> list:
272 """Return an RST heading for the given tree depth.
274 Args:
275 title: Heading text.
276 depth: Tree depth, where 0 is the document title.
278 Returns:
279 RST heading lines.
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}).")
287 marker = RST_HEADING_MARKERS[depth]
288 return [title, marker * len(title), ""]
291def _format_bytes(value: int) -> str:
292 """Format a byte count using a suitable binary unit.
294 Args:
295 value: Size in bytes.
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)
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]
310 if unit == "B":
311 return f"{value} B"
313 return f"{size:.1f} {unit}"
316def _format_artifact_size(size: int) -> str:
317 """Format an artifact size for report output.
319 Args:
320 size: Artifact size in bytes.
322 Returns:
323 Human-readable artifact size or an empty string if no size is available.
324 """
325 if size is None:
326 return ""
328 units = ("B", "KiB", "MiB", "GiB")
329 value = float(size)
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]
338 if unit == "B":
339 return f"{int(value)} {unit}"
341 return f"{value:.1f} {unit}"
344def memory_usage_block(data: dict, *, title: str = "Memory Usage") -> RstBlock:
345 """Create an RST table containing detected memory regions.
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.
352 Args:
353 data: Run data containing memory metrics.
354 title: Block title.
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 = []
362 for key in data:
363 if not key.startswith("memory_") or not key.endswith("_used"):
364 continue
366 region = key[len("memory_"):-len("_used")]
367 total_key = f"memory_{region}_total"
368 percent_key = f"memory_{region}_percent"
370 if total_key not in data or percent_key not in data:
371 continue
373 regions.append((region, data[key], data[total_key], data[percent_key]))
375 if not regions:
376 return RstBlock()
378 block = RstBlock(title)
379 block.extend([
380 ".. list-table::",
381 " :header-rows: 1",
382 "",
383 " * - Region",
384 " - Used",
385 " - Total",
386 " - Usage",
387 ])
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 ])
397 return block
400def log_artifacts_block(artifacts: list, *, title: str = "Logs") -> RstBlock:
401 """Create an RST block containing collapsible log artifacts.
403 Args:
404 artifacts: Log artifact entries returned by cikit data.
405 title: Block title.
407 Returns:
408 RST block containing cikit log directives.
409 """
410 if not artifacts:
411 return RstBlock()
413 block = RstBlock(title)
414 paths = get_sphinx_paths()
416 for artifact in artifacts:
417 block.extend([f".. cikit-log:: {paths.rst_artifact(artifact)}", ""])
419 return block
422def artifact_links_block(artifacts: list, *, title: str = "Build Artifacts") -> RstBlock:
423 """Create an RST table containing downloadable artifact metadata.
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.
429 Args:
430 artifacts: Registered artifacts to render.
431 title: Block title.
433 Returns:
434 RST block containing the artifact table.
435 """
436 if not artifacts:
437 return RstBlock()
439 block = RstBlock(title)
440 paths = get_sphinx_paths()
442 block.extend([
443 ".. list-table::",
444 " :header-rows: 1",
445 " :widths: 40 15 45",
446 "",
447 " * - Artifact",
448 " - Size",
449 " - SHA-256",
450 ])
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 ""
457 block.extend([
458 f" * - {download}",
459 f" - {size}",
460 f" - {artifact_hash}",
461 ])
463 return block