Coverage for lobster/tools/core/rst_report/_helpers.py: 72%

124 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-07-15 06:41 +0000

1#!/usr/bin/env python3 

2# 

3# lobster_rst_report - Visualise LOBSTER report as reStructuredText for Sphinx 

4# Copyright (C) 2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) 

5# 

6# This program is free software: you can redistribute it and/or modify 

7# it under the terms of the GNU Affero General Public License as 

8# published by the Free Software Foundation, either version 3 of the 

9# License, or (at your option) any later version. 

10# 

11# This program is distributed in the hope that it will be useful, but 

12# WITHOUT ANY WARRANTY; without even the implied warranty of 

13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 

14# Affero General Public License for more details. 

15# 

16# You should have received a copy of the GNU Affero General Public 

17# License along with this program. If not, see 

18# <https://www.gnu.org/licenses/>. 

19""" 

20Primitive helpers for the RST report tool. 

21 

22Organised into four static-method classes with focused concerns: 

23 

24* :class:`RstUtils` -- RST text escaping and heading generation 

25* :class:`ItemNaming` -- label, page-name, and kind-string derivation 

26* :class:`TracingClassifier` -- message classification and status-to-CSS mapping 

27* :class:`PolicyDiagramBuilder`-- Graphviz DOT generation for the tracing policy 

28""" 

29 

30from typing import Dict, Tuple 

31 

32from lobster.common.report import Report 

33from lobster.common.location import ( 

34 Void_Reference, 

35 File_Reference, 

36 Github_Reference, 

37 Codebeamer_Reference, 

38) 

39from lobster.common.items import Item, Requirement, Implementation, Activity 

40 

41 

42class RstUtils: 

43 """Pure RST text-formatting utilities.""" 

44 

45 @staticmethod 

46 def escape(text: str) -> str: 

47 """Escape characters that carry special meaning in RST inline markup. 

48 

49 The characters ``\\``, `` ` ``, ``*``, ``_``, and ``|`` are each 

50 prefixed with a backslash so they are treated as literals by Sphinx. 

51 

52 Args: 

53 text: The plain-text string to escape. 

54 

55 Returns: 

56 The escaped string, safe for use in any RST inline context. 

57 """ 

58 for ch in ("\\", "`", "*", "_", "|"): 

59 text = text.replace(ch, "\\" + ch) 

60 return text 

61 

62 @staticmethod 

63 def heading(text: str, char: str, overline: bool = False) -> list: 

64 """Return a list of RST lines that form a section heading. 

65 

66 The caller is responsible for appending a blank string after the 

67 returned lines to satisfy RST's required blank line. 

68 

69 Args: 

70 text: The heading text. 

71 char: The underline (and overline, if *overline* is ``True``) 

72 character (e.g. ``"="``, ``"-"``, ``"~"``). 

73 overline: When ``True`` an overline of the same character is 

74 added above the heading text. 

75 

76 Returns: 

77 A list of 2 lines (underline only) or 3 lines (with overline). 

78 No trailing blank line is included. 

79 """ 

80 line = char * len(text) 

81 if overline: 

82 return [line, text, line] 

83 return [text, line] 

84 

85 

86class ItemNaming: 

87 """Helpers for generating Sphinx labels and human-readable strings for 

88 LOBSTER items.""" 

89 

90 @staticmethod 

91 def item_label(item: Item) -> str: 

92 """Return the Sphinx cross-reference label for a tracing item. 

93 

94 The label is stable as long as the item's hash does not change. 

95 

96 Args: 

97 item: Any LOBSTER :class:`~lobster.common.items.Item`. 

98 

99 Returns: 

100 A string of the form ``"lobster-item-<hash>"``. 

101 """ 

102 return "lobster-item-" + item.tag.hash() 

103 

104 @staticmethod 

105 def level_label(level_name: str) -> str: 

106 """Return the Sphinx cross-reference label for a level section. 

107 

108 Args: 

109 level_name: The human-readable level name 

110 (e.g. ``"System Requirements"``). 

111 

112 Returns: 

113 A slug of the form ``"lobster-level-system-requirements"``. 

114 """ 

115 return "lobster-level-" + level_name.replace(" ", "-").lower() 

116 

117 @staticmethod 

118 def level_page_name(level_name: str) -> str: 

119 """Return a filesystem-safe page stem (no file extension) for a level. 

120 

121 All non-alphanumeric characters are replaced by underscores; 

122 consecutive underscores are collapsed; leading and trailing 

123 underscores are stripped. 

124 

125 Args: 

126 level_name: The human-readable level name. 

127 

128 Returns: 

129 A lowercase, underscore-separated identifier suitable for use as 

130 a filename stem (e.g. ``"system_requirements"``). 

131 """ 

132 safe = level_name.lower() 

133 for ch in (" ", "-", "/", "\\", "(", ")", ",", "."): 

134 safe = safe.replace(ch, "_") 

135 while "__" in safe: 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true

136 safe = safe.replace("__", "_") 

137 return safe.strip("_") or "level" 

138 

139 @staticmethod 

140 def item_kind_str(item: Item) -> str: 

141 """Return a human-readable kind label for a LOBSTER item. 

142 

143 Combines the item's framework or language with its kind, e.g. 

144 ``"TRLC Requirement"`` or ``"Python Function"``. 

145 

146 Args: 

147 item: Any LOBSTER :class:`~lobster.common.items.Item`. 

148 

149 Returns: 

150 A capitalised kind string. 

151 """ 

152 if isinstance(item, Requirement): 

153 return f"{item.framework} {item.kind.capitalize()}" 

154 if isinstance(item, Implementation): 

155 return f"{item.language} {item.kind.capitalize()}" 

156 assert isinstance(item, Activity) 

157 return f"{item.framework} {item.kind.capitalize()}" 

158 

159 @staticmethod 

160 def location_link(location, source_root: str = "") -> str: 

161 """Convert a LOBSTER location to an RST anonymous hyperlink or plain text. 

162 

163 Produces a clickable `` `text <url>`__ `` for file, GitHub, and 

164 Codebeamer references, and falls back to escaped plain text for any 

165 other location type. 

166 

167 Args: 

168 location: A LOBSTER location object. 

169 source_root: Optional URL prefix prepended to plain 

170 :class:`~lobster.common.location.File_Reference` paths. 

171 

172 Returns: 

173 An RST inline hyperlink string or plain escaped text. 

174 """ 

175 e = RstUtils.escape 

176 

177 if isinstance(location, Void_Reference): 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true

178 return "unknown location" 

179 

180 # lobster-trace: UseCases.Item_GitHub_Source 

181 # lobster-trace: rst_req.RST_Source_Root_Prefix 

182 if isinstance(location, File_Reference): 

183 href = (source_root.rstrip("/") + "/" + location.filename 

184 if source_root else location.filename) 

185 return f"`{e(location.to_string())} <{href}>`__" 

186 

187 # lobster-trace: UseCases.Item_GitHub_Source 

188 if isinstance(location, Github_Reference): 

189 url = f"{location.gh_root}/blob/{location.commit}/{location.filename}" 

190 if location.line: 190 ↛ 192line 190 didn't jump to line 192 because the condition on line 190 was always true

191 url += f"#L{location.line}" 

192 return f"`{e(location.to_string())} <{url}>`__" 

193 

194 # lobster-trace: UseCases.Show_codebeamer_links 

195 # lobster-trace: rst_req.RST_Clickable_Codebeamer_Item 

196 # lobster-trace: rst_req.RST_Codebeamer_Item_Name 

197 if isinstance(location, Codebeamer_Reference): 197 ↛ 203line 197 didn't jump to line 203 because the condition on line 197 was always true

198 url = f"{location.cb_root}/issue/{location.item}" 

199 if location.version: 199 ↛ 201line 199 didn't jump to line 201 because the condition on line 199 was always true

200 url += f"?version={location.version}" 

201 return f"`{e(location.to_string())} <{url}>`__" 

202 

203 return e(str(location)) 

204 

205 

206class TracingClassifier: 

207 """Classify tracing messages and map tracing status to sphinx-design CSS classes.""" 

208 

209 #: Mapping from :attr:`~lobster.common.items.Tracing_Status` name to 

210 #: sphinx-design card-header CSS classes. 

211 CARD_CLASSES: Dict[str, str] = { 

212 "OK": "sd-bg-success sd-text-white", 

213 "JUSTIFIED": "sd-bg-success sd-text-white", 

214 "PARTIAL": "sd-bg-warning", 

215 "MISSING": "sd-bg-danger sd-text-white", 

216 "ERROR": "sd-bg-danger sd-text-white", 

217 } 

218 

219 @staticmethod 

220 def categorize(messages: list) -> tuple: 

221 """Split issue messages into downward, upward, and general buckets. 

222 

223 Inspects each message's text to decide whether it describes a problem 

224 with a downward tracing link (traces *to* another item), an upward 

225 tracing link (derived *from* another item), or something else. 

226 

227 .. note:: 

228 Classification is based on substring matching against the following 

229 known LOBSTER message patterns: 

230 

231 * Upward: ``"up reference"``, ``"missing upward"``, 

232 ``"upward tracing"`` 

233 * Downward: ``"down reference"``, ``"missing downward"``, 

234 ``"missing reference to"``, ``"tracing destination"``, 

235 ``"unknown tracing target"``, ``"downward tracing"`` 

236 * General: everything else 

237 

238 If upstream message wording changes, update both this method and 

239 its tests. 

240 

241 Args: 

242 messages: A list of raw tracing-issue message strings. 

243 

244 Returns: 

245 A 3-tuple ``(down_msgs, up_msgs, general_msgs)`` where each 

246 element is a list of message strings. 

247 """ 

248 down, up, general = [], [], [] 

249 for msg in messages: 

250 ml = msg.lower() 

251 if "up reference" in ml or "missing upward" in ml or "upward tracing" in ml: 

252 up.append(msg) 

253 elif ("down reference" in ml or 

254 "missing downward" in ml or 

255 "missing reference to" in ml): 

256 down.append(msg) 

257 elif ("tracing destination" in ml or 257 ↛ 262line 257 didn't jump to line 262 because the condition on line 257 was always true

258 "unknown tracing target" in ml or 

259 "downward tracing" in ml): 

260 down.append(msg) 

261 else: 

262 general.append(msg) 

263 return down, up, general 

264 

265 @staticmethod 

266 def issue_tag(msg: str) -> str: 

267 """Return a concise human-readable label for a tracing issue message. 

268 

269 Transforms verbose internal messages into short display labels, e.g. 

270 ``"missing reference to Verification Test"`` becomes 

271 ``"no trace to: Verification Test"``. 

272 

273 Args: 

274 msg: A raw tracing-issue message string. 

275 

276 Returns: 

277 A short descriptive label, or the original message (RST-escaped) 

278 if no known pattern matches. 

279 """ 

280 ml = msg.lower() 

281 e = RstUtils.escape 

282 if "version" in ml and "expected" in ml: 

283 return "version mismatch" 

284 if "unknown tracing target" in ml: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true

285 target = msg.split("unknown tracing target ", 1)[-1] 

286 return f"unknown target: {e(target)}" 

287 if "missing up reference" in ml: 

288 return "no upward trace" 

289 if "missing reference to " in ml: 289 ↛ 292line 289 didn't jump to line 292 because the condition on line 289 was always true

290 target = msg.split("missing reference to ", 1)[-1] 

291 return f"no trace to: {e(target)}" 

292 if "missing down reference" in ml: 

293 return "no downward trace" 

294 return e(msg) 

295 

296 @classmethod 

297 def card_header_class(cls, status_name: str) -> str: 

298 """Return sphinx-design CSS class string for a card header. 

299 

300 Args: 

301 status_name: The ``name`` attribute of a 

302 :class:`~lobster.common.items.Tracing_Status` member 

303 (e.g. ``"OK"``, ``"MISSING"``). 

304 

305 Returns: 

306 A space-separated string of sphinx-design CSS classes. 

307 Falls back to ``"sd-bg-secondary sd-text-white"`` for unknown values. 

308 """ 

309 return cls.CARD_CLASSES.get(status_name, "sd-bg-secondary sd-text-white") 

310 

311 

312class PolicyDiagramBuilder: 

313 """Build a Graphviz DOT diagram representing the configured tracing policy. 

314 

315 Nodes represent tracing levels, coloured by kind: 

316 

317 * *requirements* -- blue (``#2196F3``) 

318 * *implementation* -- green (``#4CAF50``) 

319 * *activity* -- orange (``#FF9800``) 

320 

321 Directed edges follow the ``traces`` relationship (level A → level B means 

322 A must be traceable to B). 

323 """ 

324 

325 #: Fill and font colour pairs keyed by level kind. 

326 KIND_COLORS: Dict[str, Tuple[str, str]] = { 

327 "requirements": ("#2196F3", "white"), 

328 "implementation": ("#4CAF50", "white"), 

329 "activity": ("#FF9800", "white"), 

330 } 

331 

332 @staticmethod 

333 def dot_escape(text: str) -> str: 

334 """Escape *text* for safe embedding inside a DOT double-quoted string. 

335 

336 Args: 

337 text: Arbitrary string to use in a DOT node label or attribute. 

338 

339 Returns: 

340 The text with backslashes and double-quotes escaped. 

341 """ 

342 return text.replace("\\", "\\\\").replace('"', '\\"') 

343 

344 @classmethod 

345 def build(cls, report: Report, indent: int = 0, 

346 dot_available: bool = True) -> list: 

347 """Return RST lines for a ``.. graphviz::`` tracing-policy diagram. 

348 

349 When *dot_available* is ``False`` (e.g. Graphviz is not installed), a 

350 ``.. note::`` block is emitted instead so the HTML page remains valid. 

351 

352 Args: 

353 report: The loaded LOBSTER report whose ``config`` provides level 

354 names, kinds, and tracing relationships. 

355 indent: Number of leading spaces to prepend to each line. Use 

356 a non-zero value when embedding inside nested RST directives 

357 such as ``.. grid-item::``. 

358 dot_available: Whether the Graphviz ``dot`` executable is 

359 available. Pass the return value of 

360 :func:`~lobster.common.graphviz_utils.is_dot_available`. 

361 

362 Returns: 

363 A list of RST lines ending with a blank string. 

364 """ 

365 indent_str = " " * indent 

366 if not dot_available: 366 ↛ 376line 366 didn't jump to line 376 because the condition on line 366 was always true

367 # lobster-trace: rst_req.RST_Report_Tracing_Policy_Diagram_Fallback 

368 return [ 

369 f"{indent_str}.. note::", 

370 "", 

371 f"{indent_str} Tracing policy diagram omitted —" 

372 f" Graphviz (dot) is not installed.", 

373 "", 

374 ] 

375 # lobster-trace: rst_req.RST_Report_Tracing_Policy_Diagram 

376 nested_indent = indent_str + " " 

377 out = [] 

378 out.append(f"{indent_str}.. graphviz::") 

379 out.append("") 

380 out.append(f"{nested_indent}digraph tracing_policy {{") 

381 out.append(f"{nested_indent} rankdir=TB;") 

382 out.append( 

383 f"{nested_indent} node [shape=box, style=filled, " 

384 f'fontname="Helvetica", margin="0.3,0.1"];' 

385 ) 

386 out.append(f"{nested_indent} edge [arrowhead=open];") 

387 out.append("") 

388 for level_name, level in report.config.items(): 

389 fill, font = cls.KIND_COLORS.get(level.kind, ("#9E9E9E", "white")) 

390 safe = cls.dot_escape(level_name) 

391 out.append( 

392 f'{nested_indent} "{safe}" [fillcolor="{fill}", fontcolor="{font}"];' 

393 ) 

394 for level_name, level in report.config.items(): 

395 for trace_target in level.traces: 

396 src = cls.dot_escape(level_name) 

397 dst = cls.dot_escape(trace_target) 

398 out.append(f'{nested_indent} "{src}" -> "{dst}";') 

399 out.append(f"{nested_indent}}}") 

400 out.append("") 

401 return out