Coverage for lobster/tools/core/rst_report/_helpers.py: 94%
124 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"""
20Primitive helpers for the RST report tool.
22Organised into four static-method classes with focused concerns:
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"""
30from typing import Dict, Tuple
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
42class RstUtils:
43 """Pure RST text-formatting utilities."""
45 @staticmethod
46 def escape(text: str) -> str:
47 """Escape characters that carry special meaning in RST inline markup.
49 The characters ``\\``, `` ` ``, ``*``, ``_``, and ``|`` are each
50 prefixed with a backslash so they are treated as literals by Sphinx.
52 Args:
53 text: The plain-text string to escape.
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
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.
66 The caller is responsible for appending a blank string after the
67 returned lines to satisfy RST's required blank line.
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.
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]
86class ItemNaming:
87 """Helpers for generating Sphinx labels and human-readable strings for
88 LOBSTER items."""
90 @staticmethod
91 def item_label(item: Item) -> str:
92 """Return the Sphinx cross-reference label for a tracing item.
94 The label is stable as long as the item's hash does not change.
96 Args:
97 item: Any LOBSTER :class:`~lobster.common.items.Item`.
99 Returns:
100 A string of the form ``"lobster-item-<hash>"``.
101 """
102 return "lobster-item-" + item.tag.hash()
104 @staticmethod
105 def level_label(level_name: str) -> str:
106 """Return the Sphinx cross-reference label for a level section.
108 Args:
109 level_name: The human-readable level name
110 (e.g. ``"System Requirements"``).
112 Returns:
113 A slug of the form ``"lobster-level-system-requirements"``.
114 """
115 return "lobster-level-" + level_name.replace(" ", "-").lower()
117 @staticmethod
118 def level_page_name(level_name: str) -> str:
119 """Return a filesystem-safe page stem (no file extension) for a level.
121 All non-alphanumeric characters are replaced by underscores;
122 consecutive underscores are collapsed; leading and trailing
123 underscores are stripped.
125 Args:
126 level_name: The human-readable level name.
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:
136 safe = safe.replace("__", "_")
137 return safe.strip("_") or "level"
139 @staticmethod
140 def item_kind_str(item: Item) -> str:
141 """Return a human-readable kind label for a LOBSTER item.
143 Combines the item's framework or language with its kind, e.g.
144 ``"TRLC Requirement"`` or ``"Python Function"``.
146 Args:
147 item: Any LOBSTER :class:`~lobster.common.items.Item`.
149 Returns:
150 A capitalised kind string.
151 """
152 if isinstance(item, Requirement): 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true
153 return f"{item.framework} {item.kind.capitalize()}"
154 if isinstance(item, Implementation): 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true
155 return f"{item.language} {item.kind.capitalize()}"
156 assert isinstance(item, Activity)
157 return f"{item.framework} {item.kind.capitalize()}"
159 @staticmethod
160 def location_link(location, source_root: str = "") -> str:
161 """Convert a LOBSTER location to an RST anonymous hyperlink or plain text.
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.
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.
172 Returns:
173 An RST inline hyperlink string or plain escaped text.
174 """
175 e = RstUtils.escape
177 if isinstance(location, Void_Reference):
178 return "unknown location"
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}>`__"
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:
191 url += f"#L{location.line}"
192 return f"`{e(location.to_string())} <{url}>`__"
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):
198 url = f"{location.cb_root}/issue/{location.item}"
199 if location.version:
200 url += f"?version={location.version}"
201 return f"`{e(location.to_string())} <{url}>`__"
203 return e(str(location))
206class TracingClassifier:
207 """Classify tracing messages and map tracing status to sphinx-design CSS classes."""
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 }
219 @staticmethod
220 def categorize(messages: list) -> tuple:
221 """Split issue messages into downward, upward, and general buckets.
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.
227 .. note::
228 Classification is based on substring matching against the following
229 known LOBSTER message patterns:
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
238 If upstream message wording changes, update both this method and
239 its tests.
241 Args:
242 messages: A list of raw tracing-issue message strings.
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
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
265 @staticmethod
266 def issue_tag(msg: str) -> str:
267 """Return a concise human-readable label for a tracing issue message.
269 Transforms verbose internal messages into short display labels, e.g.
270 ``"missing reference to Verification Test"`` becomes
271 ``"no trace to: Verification Test"``.
273 Args:
274 msg: A raw tracing-issue message string.
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:
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:
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)
296 @classmethod
297 def card_header_class(cls, status_name: str) -> str:
298 """Return sphinx-design CSS class string for a card header.
300 Args:
301 status_name: The ``name`` attribute of a
302 :class:`~lobster.common.items.Tracing_Status` member
303 (e.g. ``"OK"``, ``"MISSING"``).
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")
312class PolicyDiagramBuilder:
313 """Build a Graphviz DOT diagram representing the configured tracing policy.
315 Nodes represent tracing levels, coloured by kind:
317 * *requirements* -- blue (``#2196F3``)
318 * *implementation* -- green (``#4CAF50``)
319 * *activity* -- orange (``#FF9800``)
321 Directed edges follow the ``traces`` relationship (level A → level B means
322 A must be traceable to B).
323 """
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 }
332 @staticmethod
333 def dot_escape(text: str) -> str:
334 """Escape *text* for safe embedding inside a DOT double-quoted string.
336 Args:
337 text: Arbitrary string to use in a DOT node label or attribute.
339 Returns:
340 The text with backslashes and double-quotes escaped.
341 """
342 return text.replace("\\", "\\\\").replace('"', '\\"')
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.
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.
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`.
362 Returns:
363 A list of RST lines ending with a blank string.
364 """
365 indent_str = " " * indent
366 if not dot_available: 366 ↛ 368line 366 didn't jump to line 368 because the condition on line 366 was never 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: 395 ↛ 396line 395 didn't jump to line 396 because the loop on line 395 never started
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