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
« 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.
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`.
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"""
37import os
38import argparse
39import subprocess
40from typing import Dict, Optional, Sequence
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
50from ._helpers import RstUtils, ItemNaming
51from ._renderers import (
52 _KIND_ORDER,
53 _build_page_map,
54 LevelSectionBuilder,
55 CoverageGridBuilder,
56 IssuesListBuilder,
57)
60def _get_git_commit() -> str:
61 """Return the HEAD commit hash of the current repository, or ``'(unknown)'``.
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)"
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)
94def write_rst(report: Report, source_root: str = "") -> str:
95 """Render a LOBSTER report as a single reStructuredText string.
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.
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.
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()
119 lines = []
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("")
129 # Coverage table + tracing-policy diagram (rubric = not a TOC entry)
130 lines.append(".. rubric:: Coverage Summary")
131 lines.append("")
133 def ref_fn(n):
134 return f":ref:`{RstUtils.escape(n)} <{ItemNaming.level_label(n)}>`"
136 lines += CoverageGridBuilder(report).build(ref_fn, dot_available=dot_available)
138 # Issues summary (rubric = not a TOC entry)
139 lines.append(".. rubric:: Issues")
140 lines.append("")
141 lines += IssuesListBuilder(report).build()
142 lines.append("")
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 }
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
155 lines += RstUtils.heading(kind_title, "=")
156 lines.append("")
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("")
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
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()
178 return "\n".join(lines) + "\n"
181def write_rst_to_file(rst_content: str, output_path: str) -> None:
182 """Write RST content to *output_path*, creating parent directories as needed.
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)
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
203def write_rst_pages(report: Report, source_root: str = "") -> Dict[str, str]:
204 """Render a LOBSTER report as a set of linked RST pages.
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.
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.
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 }
227 pages = {}
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]
235 lines = []
236 lines += RstUtils.heading(level_name, "=")
237 lines.append("")
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()
250 pages[f"{stem}.rst"] = "\n".join(lines) + "\n"
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()
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("")
271 # Coverage table + policy diagram (rubric = not a TOC entry)
272 lines.append(".. rubric:: Coverage Summary")
273 lines.append("")
275 def ref_fn(n):
276 return f":doc:`{RstUtils.escape(n)} <{page_map[n]}>`"
278 lines += CoverageGridBuilder(report).build(ref_fn, dot_available=dot_available)
280 # Issues list (rubric = not a TOC entry)
281 lines.append(".. rubric:: Issues")
282 lines.append("")
283 lines += IssuesListBuilder(report).build()
284 lines.append("")
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("")
300 pages["index.rst"] = "\n".join(lines) + "\n"
302 return pages
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.
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)
321# ---------------------------------------------------------------------------
322# CLI tool
323# ---------------------------------------------------------------------------
326class RstReportTool(MetaDataToolBase):
327 """Argparse-based CLI tool for generating RST reports from LOBSTER data.
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 """
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 )
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 )
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 )
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 )
371 def _run_impl(self, options: argparse.Namespace) -> int:
372 """Execute the tool with the parsed command-line options.
374 Args:
375 options: Parsed argument namespace from argparse.
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")
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
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}")
413 return 0
416# ---------------------------------------------------------------------------
417# Public convenience API
418# ---------------------------------------------------------------------------
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.
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 )
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.
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 )
460def main(args: Optional[Sequence[str]] = None) -> int:
461 """Entry point for the ``lobster-rst-report`` command-line tool.
463 Args:
464 args: Optional list of CLI argument strings. When ``None`` (default),
465 ``sys.argv[1:]`` is used.
467 Returns:
468 Integer exit code (0 on success).
469 """
470 return RstReportTool().run(args)