Coverage for lobster/tools/core/rst_report/rst_report.py: 0%

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

20LOBSTER RST report tool -- top-level orchestration and CLI. 

21 

22This module is intentionally thin: it wires together the helpers from 

23:mod:`_helpers` and the builder classes from :mod:`_renderers` to produce 

24complete RST documents, and exposes the CLI entry-point :func:`main`. 

25 

26Public API 

27---------- 

28* :func:`write_rst` -- single-page RST string 

29* :func:`write_rst_to_file` -- single-page RST to file 

30* :func:`write_rst_pages` -- multi-page RST dict (filename to content) 

31* :func:`write_rst_pages_to_dir` -- write multi-page dict to a directory 

32* :func:`lobster_rst_report` -- convenience wrapper (single-page) 

33* :func:`lobster_rst_report_pages` -- convenience wrapper (multi-page) 

34* :func:`main` -- argparse CLI entry-point 

35""" 

36 

37import os 

38import argparse 

39import subprocess 

40from typing import Dict, Optional, Sequence 

41 

42from lobster.common.version import LOBSTER_VERSION 

43from lobster.common.report import Report 

44from lobster.common.io import ensure_output_directory 

45from lobster.common.meta_data_tool_base import MetaDataToolBase 

46from lobster.common.exceptions import LOBSTER_Exception 

47from lobster.common.errors import LOBSTER_Error 

48from lobster.common.graphviz_utils import is_dot_available 

49 

50from ._helpers import RstUtils, ItemNaming 

51from ._renderers import ( 

52 _KIND_ORDER, 

53 _build_page_map, 

54 LevelSectionBuilder, 

55 CoverageGridBuilder, 

56 IssuesListBuilder, 

57) 

58 

59 

60def _get_git_commit() -> str: 

61 """Return the HEAD commit hash of the current repository, or ``'(unknown)'``. 

62 

63 Falls back gracefully when git is not installed or the current directory is 

64 not inside a git repository, so that the tool remains usable outside of 

65 version-controlled environments. 

66 """ 

67 # lobster-trace: rst_req.RST_Report_Header 

68 try: 

69 result = subprocess.run( 

70 ["git", "rev-parse", "HEAD"], 

71 stdout=subprocess.PIPE, 

72 stderr=subprocess.PIPE, 

73 encoding="UTF-8", 

74 check=True, 

75 timeout=5, 

76 ) 

77 return result.stdout.strip() 

78 except (FileNotFoundError, subprocess.TimeoutExpired, 

79 subprocess.CalledProcessError, OSError): 

80 return "(unknown)" 

81 

82 

83# --------------------------------------------------------------------------- 

84# Single-page output 

85# --------------------------------------------------------------------------- 

86# 

87# Heading hierarchy (first appearance determines RST level): 

88# overline/# -> document title 

89# = -> kind groups (Requirements and Specification, ...) 

90# - -> level names (System Requirements, ...) 

91# ~ -> status groups inside each level (Issues N items, OK Items M items) 

92 

93 

94def write_rst(report: Report, source_root: str = "") -> str: 

95 """Render a LOBSTER report as a single reStructuredText string. 

96 

97 The document contains a coverage summary grid (table + tracing-policy 

98 diagram), an issues list, and then the full item detail for every level 

99 grouped by kind. Coverage Summary and Issues use ``.. rubric::`` so they 

100 do not appear as TOC entries in the Sphinx sidebar. 

101 

102 Args: 

103 report: A loaded :class:`~lobster.common.report.Report` instance. 

104 source_root: Optional URL prefix prepended to plain file-reference 

105 paths. 

106 

107 Returns: 

108 The complete RST document as a string (ending with a newline). 

109 """ 

110 dot_available = is_dot_available() 

111 if not dot_available: 

112 print( 

113 "warning: dot utility not found, report will not include " 

114 "the tracing policy visualisation" 

115 ) 

116 print("> please install Graphviz (https://graphviz.org)") 

117 commit = _get_git_commit() 

118 

119 lines = [] 

120 

121 title = "L.O.B.S.T.E.R. Traceability Report" 

122 lines += RstUtils.heading(title, "#", overline=True) 

123 lines.append("") 

124 # lobster-trace: rst_req.RST_Report_Header 

125 lines.append(f"| Analyzed commit: {commit}") 

126 lines.append(f"| LOBSTER Version: {LOBSTER_VERSION}") 

127 lines.append("") 

128 

129 # Coverage table + tracing-policy diagram (rubric = not a TOC entry) 

130 lines.append(".. rubric:: Coverage Summary") 

131 lines.append("") 

132 

133 def ref_fn(n): 

134 return f":ref:`{RstUtils.escape(n)} <{ItemNaming.level_label(n)}>`" 

135 

136 lines += CoverageGridBuilder(report).build(ref_fn, dot_available=dot_available) 

137 

138 # Issues summary (rubric = not a TOC entry) 

139 lines.append(".. rubric:: Issues") 

140 lines.append("") 

141 lines += IssuesListBuilder(report).build() 

142 lines.append("") 

143 

144 # Detailed content: kind groups and level sections 

145 items_by_level = { 

146 lv: [it for it in report.items.values() if it.level == lv] 

147 for lv in report.config 

148 } 

149 

150 for kind, kind_title in _KIND_ORDER: 

151 levels_of_kind = [lv for lv in report.config.values() if lv.kind == kind] 

152 if not levels_of_kind: 

153 continue 

154 

155 lines += RstUtils.heading(kind_title, "=") 

156 lines.append("") 

157 

158 for level in levels_of_kind: 

159 lines.append(f".. _{ItemNaming.level_label(level.name)}:") 

160 lines.append("") 

161 lines += RstUtils.heading(level.name, "-") 

162 lines.append("") 

163 

164 items = items_by_level[level.name] 

165 if not items: 

166 lines.append("No items recorded at this level.") 

167 lines.append("") 

168 continue 

169 

170 data = report.coverage[level.name] 

171 lines.append( 

172 f"**Coverage:** {data.coverage:.1f}%" 

173 f" ({data.ok} of {data.items} items OK)" 

174 ) 

175 lines.append("") 

176 lines += LevelSectionBuilder(items, report, source_root).build() 

177 

178 return "\n".join(lines) + "\n" 

179 

180 

181def write_rst_to_file(rst_content: str, output_path: str) -> None: 

182 """Write RST content to *output_path*, creating parent directories as needed. 

183 

184 Args: 

185 rst_content: The RST string to write. 

186 output_path: The destination file path. 

187 """ 

188 ensure_output_directory(output_path) 

189 with open(output_path, "w", encoding="UTF-8") as fd: 

190 fd.write(rst_content) 

191 

192 

193# --------------------------------------------------------------------------- 

194# Multi-page output 

195# --------------------------------------------------------------------------- 

196# 

197# Heading hierarchy: 

198# index.rst -- overline/# title only; Coverage/Issues are rubrics (not in 

199# TOC); kind groups are toctree :caption: entries (not headings) 

200# level pages -- = for page title, - for Issues/OK Items sub-sections 

201 

202 

203def write_rst_pages(report: Report, source_root: str = "") -> Dict[str, str]: 

204 """Render a LOBSTER report as a set of linked RST pages. 

205 

206 Produces one RST file per tracing level plus an ``index.rst`` that links 

207 them together. The index page contains the coverage grid, issues list, 

208 and per-kind ``.. toctree::`` directives whose ``:caption:`` entries 

209 appear in the Sphinx sidebar without adding clickable heading nodes. 

210 

211 Args: 

212 report: A loaded :class:`~lobster.common.report.Report` instance. 

213 source_root: Optional URL prefix prepended to plain file-reference 

214 paths. 

215 

216 Returns: 

217 A ``dict`` mapping filename (e.g. ``"index.rst"``, 

218 ``"system_requirements.rst"``) to RST content strings. 

219 """ 

220 page_map = _build_page_map(report) 

221 coverage = report.coverage 

222 items_by_level = { 

223 lv: [it for it in report.items.values() if it.level == lv] 

224 for lv in report.config 

225 } 

226 

227 pages = {} 

228 

229 # -- Level pages -- 

230 for level_name in report.config: 

231 stem = page_map[level_name] 

232 data = coverage[level_name] 

233 items = items_by_level[level_name] 

234 

235 lines = [] 

236 lines += RstUtils.heading(level_name, "=") 

237 lines.append("") 

238 

239 if not items: 

240 lines.append("No items recorded at this level.") 

241 lines.append("") 

242 else: 

243 lines.append( 

244 f"**Coverage:** {data.coverage:.1f}%" 

245 f" ({data.ok} of {data.items} items OK)" 

246 ) 

247 lines.append("") 

248 lines += LevelSectionBuilder(items, report, source_root).build() 

249 

250 pages[f"{stem}.rst"] = "\n".join(lines) + "\n" 

251 

252 # -- Index page -- 

253 dot_available = is_dot_available() 

254 if not dot_available: 

255 print( 

256 "warning: dot utility not found, report will not include " 

257 "the tracing policy visualisation" 

258 ) 

259 print("> please install Graphviz (https://graphviz.org)") 

260 commit = _get_git_commit() 

261 

262 lines = [] 

263 title = "L.O.B.S.T.E.R. Traceability Report" 

264 lines += RstUtils.heading(title, "#", overline=True) 

265 lines.append("") 

266 # lobster-trace: rst_req.RST_Report_Header 

267 lines.append(f"| Analyzed commit: {commit}") 

268 lines.append(f"| LOBSTER Version: {LOBSTER_VERSION}") 

269 lines.append("") 

270 

271 # Coverage table + policy diagram (rubric = not a TOC entry) 

272 lines.append(".. rubric:: Coverage Summary") 

273 lines.append("") 

274 

275 def ref_fn(n): 

276 return f":doc:`{RstUtils.escape(n)} <{page_map[n]}>`" 

277 

278 lines += CoverageGridBuilder(report).build(ref_fn, dot_available=dot_available) 

279 

280 # Issues list (rubric = not a TOC entry) 

281 lines.append(".. rubric:: Issues") 

282 lines.append("") 

283 lines += IssuesListBuilder(report).build() 

284 lines.append("") 

285 

286 # Per-kind toctrees -- :caption: shows in sidebar but doesn't create a 

287 # heading node, so clicking a level goes straight to that level's page 

288 for kind, kind_title in _KIND_ORDER: 

289 levels_of_kind = [lv for lv in report.config.values() if lv.kind == kind] 

290 if not levels_of_kind: 

291 continue 

292 lines.append(".. toctree::") 

293 lines.append(f" :caption: {kind_title}") 

294 lines.append(" :maxdepth: 1") 

295 lines.append("") 

296 for lv in levels_of_kind: 

297 lines.append(f" {page_map[lv.name]}") 

298 lines.append("") 

299 

300 pages["index.rst"] = "\n".join(lines) + "\n" 

301 

302 return pages 

303 

304 

305def write_rst_pages_to_dir(pages: Dict[str, str], out_dir: str) -> None: 

306 """Write multi-page RST output to *out_dir*, creating it if necessary. 

307 

308 Args: 

309 pages: A ``dict`` mapping filename to RST content, as returned by 

310 :func:`write_rst_pages`. 

311 out_dir: The directory to write files into. Created if it does not 

312 already exist. 

313 """ 

314 os.makedirs(out_dir, exist_ok=True) 

315 for filename, content in pages.items(): 

316 filepath = os.path.join(out_dir, filename) 

317 with open(filepath, "w", encoding="UTF-8") as fd: 

318 fd.write(content) 

319 

320 

321# --------------------------------------------------------------------------- 

322# CLI tool 

323# --------------------------------------------------------------------------- 

324 

325 

326class RstReportTool(MetaDataToolBase): 

327 """Argparse-based CLI tool for generating RST reports from LOBSTER data. 

328 

329 Registered as the ``rst-report`` tool in the LOBSTER tool registry. 

330 Supports both single-page (``--out``) and multi-page (``--out-dir``) output. 

331 """ 

332 

333 def __init__(self): 

334 """Register the tool with its name, description, and argument parser.""" 

335 super().__init__( 

336 name="rst-report", 

337 description="Visualise LOBSTER report as reStructuredText for Sphinx", 

338 official=True, 

339 ) 

340 

341 ap = self._argument_parser 

342 ap.add_argument( 

343 "lobster_report", 

344 nargs="?", 

345 default="report.lobster", 

346 help="path to the LOBSTER report file (default: report.lobster)", 

347 ) 

348 

349 output_group = ap.add_mutually_exclusive_group() 

350 output_group.add_argument( 

351 "--out", 

352 default=None, 

353 metavar="FILE", 

354 help="write single-page RST to FILE (default: lobster_report.rst)", 

355 ) 

356 output_group.add_argument( 

357 "--out-dir", 

358 default=None, 

359 metavar="DIR", 

360 help="write multi-page RST to DIR (index.rst + one page per level)", 

361 ) 

362 

363 ap.add_argument( 

364 "--source-root", 

365 default="", 

366 help="prefix prepended to file reference URLs, e.g. a relative " 

367 "path from the RST output location back to the workspace root; " 

368 "a trailing '/' is optional and will be added automatically", 

369 ) 

370 

371 def _run_impl(self, options: argparse.Namespace) -> int: 

372 """Execute the tool with the parsed command-line options. 

373 

374 Args: 

375 options: Parsed argument namespace from argparse. 

376 

377 Returns: 

378 Integer exit code (0 on success). 

379 """ 

380 # lobster-trace: rst_req.Missing_Lobster_File 

381 if not os.path.isfile(options.lobster_report): 

382 self._argument_parser.error(f"{options.lobster_report} is not a file") 

383 

384 try: 

385 report = Report() 

386 report.load_report(options.lobster_report) 

387 except LOBSTER_Error as err: 

388 print(err) 

389 print(f"{self.name}: aborting due to earlier errors.") 

390 return 1 

391 except LOBSTER_Exception as err: 

392 err.dump() 

393 return 1 

394 

395 # lobster-trace: UseCases.RST_Output 

396 # lobster-trace: rst_req.RST_Report_Multi_Page 

397 # lobster-trace: rst_req.Valid_Lobster_File_Multi_Page 

398 if options.out_dir is not None: 

399 pages = write_rst_pages(report=report, source_root=options.source_root) 

400 write_rst_pages_to_dir(pages, options.out_dir) 

401 print( 

402 f"LOBSTER RST report written to {options.out_dir}/ ({len(pages)} files)" 

403 ) 

404 # lobster-trace: UseCases.RST_Output 

405 # lobster-trace: rst_req.RST_Report_Single_Page 

406 # lobster-trace: rst_req.Valid_Lobster_File 

407 else: 

408 out_path = options.out if options.out is not None else "lobster_report.rst" 

409 rst_content = write_rst(report=report, source_root=options.source_root) 

410 write_rst_to_file(rst_content, out_path) 

411 print(f"LOBSTER RST report written to {out_path}") 

412 

413 return 0 

414 

415 

416# --------------------------------------------------------------------------- 

417# Public convenience API 

418# --------------------------------------------------------------------------- 

419 

420 

421def lobster_rst_report( 

422 lobster_report_path: str, 

423 output_rst_path: str, 

424 source_root: str = "", 

425) -> None: 

426 """Generate a single-page RST report from a LOBSTER report file. 

427 

428 Args: 

429 lobster_report_path: Path to the input ``.lobster`` report file. 

430 output_rst_path: Path to the output RST file to create. 

431 source_root: Optional URL prefix prepended to file-reference paths. 

432 """ 

433 report = Report() 

434 report.load_report(lobster_report_path) 

435 write_rst_to_file( 

436 write_rst(report=report, source_root=source_root), output_rst_path 

437 ) 

438 

439 

440def lobster_rst_report_pages( 

441 lobster_report_path: str, 

442 output_dir: str, 

443 source_root: str = "", 

444) -> None: 

445 """Generate a multi-page RST report from a LOBSTER report file. 

446 

447 Args: 

448 lobster_report_path: Path to the input ``.lobster`` report file. 

449 output_dir: Directory to write RST pages into. 

450 source_root: Optional URL prefix prepended to file-reference paths. 

451 """ 

452 report = Report() 

453 report.load_report(lobster_report_path) 

454 write_rst_pages_to_dir( 

455 write_rst_pages(report=report, source_root=source_root), 

456 output_dir, 

457 ) 

458 

459 

460def main(args: Optional[Sequence[str]] = None) -> int: 

461 """Entry point for the ``lobster-rst-report`` command-line tool. 

462 

463 Args: 

464 args: Optional list of CLI argument strings. When ``None`` (default), 

465 ``sys.argv[1:]`` is used. 

466 

467 Returns: 

468 Integer exit code (0 on success). 

469 """ 

470 return RstReportTool().run(args)