Coverage for lobster/tools/core/html_report/html_report.py: 84%
345 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-05-12 15:02 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-05-12 15:02 +0000
1#!/usr/bin/env python3
2#
3# lobster_html_report - Visualise LOBSTER report in HTML
4# Copyright (C) 2022-2025 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/>.
19import os.path
20import argparse
21import html
22import subprocess
23import hashlib
24import tempfile
25from datetime import datetime, timezone
26from typing import Optional, Sequence
28import markdown
30from lobster.common.version import LOBSTER_VERSION
31from lobster.htmldoc import htmldoc
32from lobster.common.report import Report
33from lobster.common.io import ensure_output_directory
34from lobster.common.location import (Void_Reference,
35 File_Reference,
36 Github_Reference,
37 Codebeamer_Reference)
38from lobster.common.items import (Tracing_Status, Item,
39 Requirement, Implementation,
40 Activity)
41from lobster.common.meta_data_tool_base import MetaDataToolBase
42from lobster.tools.core.html_report.html_report_css import CSS
43from lobster.tools.core.html_report.html_report_js import JAVA_SCRIPT
46LOBSTER_GH = "https://github.com/bmw-software-engineering/lobster"
49def is_dot_available(dot):
50 try:
51 subprocess.run([dot if dot else "dot", "-V"],
52 stdout=subprocess.PIPE,
53 stderr=subprocess.PIPE,
54 encoding="UTF-8",
55 check=True)
56 return True
57 except FileNotFoundError:
58 return False
61def name_hash(name):
62 hobj = hashlib.md5()
63 hobj.update(name.encode("UTF-8"))
64 return hobj.hexdigest()
67def xref_item(item, link=True, brief=False):
68 assert isinstance(item, Item)
69 assert isinstance(link, bool)
70 assert isinstance(brief, bool)
72 if brief: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true
73 rv = ""
74 elif isinstance(item, Requirement):
75 rv = html.escape(item.framework + " " +
76 item.kind.capitalize())
77 elif isinstance(item, Implementation):
78 rv = html.escape(item.language + " " +
79 item.kind.capitalize())
80 else:
81 assert isinstance(item, Activity)
82 rv = html.escape(item.framework + " " +
83 item.kind.capitalize())
84 if not brief: 84 ↛ 87line 84 didn't jump to line 87 because the condition on line 84 was always true
85 rv += " "
87 if link:
88 rv += f"<a href='#item-{item.tag.hash()}'>{html.escape(item.name)}</a>"
89 else:
90 rv += html.escape(item.name)
92 return rv
95def create_policy_diagram(doc, report, dot):
96 assert isinstance(doc, htmldoc.Document)
97 assert isinstance(report, Report)
99 graph = 'digraph "LOBSTER Tracing Policy" {\n'
100 for level in report.config.values():
101 if level.kind == "requirements":
102 style = 'shape=box, style=rounded'
103 elif level.kind == "implementation":
104 style = 'shape=box'
105 else:
106 assert level.kind == "activity"
107 style = 'shape=hexagon'
108 style += f', href="#sec-{name_hash(level.name)}"'
110 graph += f' n_{name_hash(level.name)} [label="{level.name}", {style}];\n'
112 for level in report.config.values():
113 source = name_hash(level.name)
114 for target in map(name_hash, level.traces):
115 # Not a mistake; we want to show the tracing down, whereas
116 # in the config file we indicate how we trace up.
117 graph += f' n_{target} -> n_{source};\n'
118 graph += "}\n"
120 with tempfile.TemporaryDirectory() as tmp_dir:
121 graph_name = os.path.join(tmp_dir, "graph.dot")
122 with open(graph_name, "w", encoding="UTF-8") as tmp_fd:
123 tmp_fd.write(graph)
124 svg = subprocess.run([dot if dot else "dot", "-Tsvg", graph_name],
125 stdout=subprocess.PIPE,
126 encoding="UTF-8",
127 check=True)
128 assert svg.returncode == 0
129 image = svg.stdout[svg.stdout.index("<svg "):]
131 for line in image.splitlines():
132 doc.add_line(line)
135def create_item_coverage(doc, report):
136 assert isinstance(doc, htmldoc.Document)
137 assert isinstance(report, Report)
139 doc.add_line("<table>")
140 doc.add_line("<thead><tr>")
141 doc.add_line("<td>Category</td>")
142 doc.add_line("<td>Ratio</td>")
143 doc.add_line("<td>Coverage</td>")
144 doc.add_line("<td>OK Items</td>")
145 doc.add_line("<td>Total Items</td>")
146 doc.add_line("</tr><thead>")
147 doc.add_line("<tbody>")
148 doc.add_line("</tbody>")
149 for level in report.config.values():
150 data = report.coverage[level.name]
151 doc.add_line(
152 f'<tr class="coverage-table-{level.name.replace(" ", "-").lower()}">'
153 )
154 doc.add_line(
155 f'<td><a href="#sec-{name_hash(level.name)}">'
156 f'{html.escape(level.name)}</a></td>'
157 )
158 doc.add_line(f"<td>{data.coverage:.1f}%</td>")
159 doc.add_line("<td>")
160 doc.add_line(f'<progress value="{data.ok}" max="{data.items}">')
161 doc.add_line(f"{data.coverage:.2f}%")
162 doc.add_line('</progress>')
163 doc.add_line("</td>")
164 doc.add_line(f'<td align="right">{data.ok}</td>')
165 doc.add_line(f'<td align="right">{data.items}</td>')
166 doc.add_line("</tr>")
167 doc.add_line("</table>")
170def run_git_show(commit_hash, path=None):
171 """Run `git show` command to get the commit timestamp."""
172 cmd = ['git'] + (['-C', path] if path else []) + [
173 'show', '-s', '--format=%ct', commit_hash]
174 try:
175 output = subprocess.run(cmd, capture_output=True, text=True, check=True)
176 if output.stdout.strip(): 176 ↛ 182line 176 didn't jump to line 182 because the condition on line 176 was always true
177 epoch = int(output.stdout.strip())
178 return str(datetime.fromtimestamp(epoch, tz=timezone.utc)) + " UTC"
179 except subprocess.CalledProcessError:
180 location = f"submodule path: {path}" if path else "main repository"
181 print(f"[Warning] Could not find commit {commit_hash} in {location}.")
182 return None
185def get_commit_timestamp_utc(commit_hash, submodule_path=None):
186 """Get commit timestamp in UTC format, either from main repo or submodule."""
187 timestamp = run_git_show(commit_hash)
188 if timestamp: 188 ↛ 191line 188 didn't jump to line 191 because the condition on line 188 was always true
189 return f"{timestamp}"
191 if submodule_path:
192 timestamp = run_git_show(commit_hash, submodule_path)
193 if timestamp:
194 return f"{timestamp} (from submodule at {submodule_path})"
196 return "Unknown"
199def write_item_box_begin(doc, item, report):
200 assert isinstance(doc, htmldoc.Document)
201 assert isinstance(item, Item)
203 doc.add_line(f'<!-- begin item {html.escape(item.tag.key())} -->')
205 doc.add_line(f'<div class="item-{html.escape(item.tracing_status.name.lower())}" '
206 f'id="item-{item.tag.hash()}">')
208 svg_icon = (
209 '<svg class="icon"><use href="#svg-check-square"></use></svg>'
210 if item.tracing_status in (Tracing_Status.OK, Tracing_Status.JUSTIFIED)
211 else '<svg class="icon"><use href="#svg-alert-triangle"></use></svg>'
212 )
213 item_div_content = f'{svg_icon} {xref_item(item, link=False)}'
214 doc.add_line(f'<div class="item-name">{item_div_content}</div>')
216 doc.add_line('<div class="attribute">Source: ')
217 doc.add_line('<svg class="icon"><use href="#svg-external-link"></use></svg>')
219 doc.add_line(item.location.to_html(source_root=report.source_root))
220 doc.add_line("</div>")
223def write_item_tracing(doc, report, item):
224 assert isinstance(doc, htmldoc.Document)
225 assert isinstance(report, Report)
226 assert isinstance(item, Item)
228 doc.add_line('<div class="attribute">')
229 if item.ref_down:
230 doc.add_line("<div>Traces to:")
231 doc.add_line("<ul>")
232 for ref in item.ref_down:
233 doc.add_line(f"<li>{xref_item(report.items[ref.key()])}</li>")
234 doc.add_line("</ul>")
235 doc.add_line("</div>")
236 if item.ref_up:
237 doc.add_line("<div>Derived from:")
238 doc.add_line("<ul>")
239 for ref in item.ref_up:
240 doc.add_line(f"<li>{xref_item(report.items[ref.key()])}</li>")
241 doc.add_line("</ul>")
242 doc.add_line("</div>")
244 if item.tracing_status == Tracing_Status.JUSTIFIED:
245 doc.add_line("<div>Justifications:")
246 doc.add_line("<ul>")
247 for msg in item.just_global + item.just_up + item.just_down:
248 doc.add_line(f"<li>{html.escape(msg)}</li>")
249 doc.add_line("</ul>")
250 doc.add_line("</div>")
252 if item.messages:
253 doc.add_line("<div>Issues:")
254 doc.add_line("<ul>")
255 for msg in item.messages:
256 doc.add_line(f"<li>{html.escape(msg)}</li>")
257 doc.add_line("</ul>")
258 doc.add_line("</div>")
260 doc.add_line("</div>")
263def write_item_box_end(doc, item):
264 assert isinstance(doc, htmldoc.Document)
266 if getattr(item.location, "commit", None) is not None:
267 commit_hash = item.location.commit
268 timestamp = get_commit_timestamp_utc(commit_hash, item.location.gh_repo)
269 doc.add_line(
270 f'<div class="attribute">'
271 f'Build Reference: <strong>{commit_hash}</strong> | '
272 f'Timestamp: {timestamp}'
273 f'</div>'
274 )
275 doc.add_line("</div>")
276 doc.add_line('<!-- end item -->')
279def generate_custom_data(report) -> str:
280 content = [
281 f"{key}: {value}<br>"
282 for key, value in report.custom_data.items()
283 if value
284 ]
285 return "".join(content)
288def write_html_to_file(html_content: str, output_path: str) -> None:
289 """Write HTML content to file, creating parent directories if needed."""
290 ensure_output_directory(output_path)
291 with open(output_path, "w", encoding="UTF-8") as fd:
292 fd.write(html_content)
293 fd.write("\n")
296def write_html(report, dot, high_contrast, render_md) -> str:
297 assert isinstance(report, Report)
299 doc = htmldoc.Document(
300 "L.O.B.S.T.E.R.",
301 "Lightweight Open BMW Software Traceability Evidence Report"
302 )
304 # Item styles
305 doc.style["#custom-data-banner"] = {
306 "position": "absolute",
307 "top": "1em",
308 "right": "2em",
309 "font-size": "0.9em",
310 "color": "white",
311 }
312 doc.style[".item-ok, .item-partial, .item-missing, .item-justified"] = {
313 "border" : "1px solid black",
314 "border-radius" : "0.5em",
315 "margin-top" : "0.4em",
316 "padding" : "0.25em",
317 }
318 doc.style[".item-ok:target, "
319 ".item-partial:target, "
320 ".item-missing:target, "
321 ".item-justified:target"] = {
322 "border" : "3px solid black",
323 }
324 doc.style[".subtle-ok, "
325 ".subtle-partial, "
326 ".subtle-missing, "
327 ".subtle-justified"] = {
328 "padding-left" : "0.2em",
329 }
330 doc.style[".item-ok"] = {
331 "background-color" : "#b2e1b2" if high_contrast else "#efe",
332 }
333 doc.style[".item-partial"] = {
334 "background-color" : "#ffe",
335 }
336 doc.style[".item-missing"] = {
337 "background-color" : "#ffb2ff" if high_contrast else "#fee",
338 }
339 doc.style[".item-justified"] = {
340 "background-color" : "#eee",
341 }
342 doc.style[".subtle-ok"] = {
343 "border-left" : "0.2em solid #8f8",
344 }
345 doc.style[".subtle-partial"] = {
346 "border-left" : "0.2em solid #ff8",
347 }
348 doc.style[".subtle-missing"] = {
349 "border-left" : "0.2em solid #f88",
350 }
351 doc.style[".subtle-justified"] = {
352 "border-left" : "0.2em solid #888",
353 }
354 doc.style[".item-name"] = {
355 "font-size" : "125%",
356 "font-weight" : "bold",
357 }
358 doc.style[".attribute"] = {
359 "margin-top" : "0.5em",
360 }
362 # Render MD
363 if render_md:
364 doc.style[".md_description"] = {
365 "font-style" : "unset",
366 }
367 doc.style[".md_description h1"] = {
368 "padding" : "unset",
369 "margin" : "unset"
370 }
371 doc.style[".md_description h2"] = {
372 "padding" : "unset",
373 "margin" : "unset",
374 "border-bottom" : "unset",
375 "text-align" : "unset"
376 }
378 # Columns
379 doc.style[".columns"] = {
380 "display" : "flex",
381 }
382 doc.style[".columns .column"] = {
383 "flex" : "45%",
384 }
386 # Tables
387 doc.style["thead tr"] = {
388 "font-weight" : "bold",
389 }
390 doc.style["tbody tr.alt"] = {
391 "background-color" : "#eee",
392 }
394 # Text
395 doc.style["blockquote"] = {
396 "font-style" : "italic",
397 "border-left" : "0.2em solid gray",
398 "padding-left" : "0.4em",
399 "margin-left" : "0.5em",
400 }
402 # Footer
403 doc.style["footer"] = {
404 "margin-top" : "1rem",
405 "padding" : ".2rem",
406 "text-align" : "right",
407 "color" : "#666",
408 "font-size" : ".7rem",
409 }
411 ### Menu & Navigation
412 doc.navbar.add_link("Overview", "#sec-overview")
413 doc.navbar.add_link("Issues", "#sec-issues")
414 menu = doc.navbar.add_dropdown("Detailed report")
415 for level in report.config.values():
416 menu.add_link(level.name, "#sec-" + name_hash(level.name))
417 # doc.navbar.add_link("Software Traceability Matrix", "#matrix")
418 if report.custom_data:
419 content = generate_custom_data(report)
420 doc.add_line(f'<div id="custom-data-banner">{content}</div>')
421 menu = doc.navbar.add_dropdown("LOBSTER", "right")
422 menu.add_link("Documentation",
423 f"{LOBSTER_GH}/blob/main/README.md")
424 menu.add_link("License",
425 f"{LOBSTER_GH}/blob/main/LICENSE.md")
426 menu.add_link("Source", LOBSTER_GH)
428 ### Summary (Coverage & Policy)
429 doc.add_heading(2, "Overview", "overview", html_identifier=True)
430 doc.add_line('<div class="columns">')
431 doc.add_line('<div class="column">')
432 doc.add_heading(3, "Coverage", html_identifier=True)
433 create_item_coverage(doc, report)
434 doc.add_line('</div>')
435 if is_dot_available(dot): 435 ↛ 436line 435 didn't jump to line 436 because the condition on line 435 was never true
436 doc.add_line('<div class="column">')
437 doc.add_heading(3, "Tracing policy")
438 create_policy_diagram(doc, report, dot)
439 doc.add_line('</div>')
440 else:
441 print("warning: dot utility not found, report will not "
442 "include the tracing policy visualisation")
443 print("> please install Graphviz (https://graphviz.org)")
444 doc.add_line('</div>')
446 ### Filtering
447 doc.add_heading(2, "Filtering", "filtering-options", html_identifier=True)
448 doc.add_heading(3, "Item Filters", html_identifier=True)
449 doc.add_line('<div id = "btnFilterItem">')
450 doc.add_line('<button class="button buttonAll buttonActive" '
451 'onclick="buttonFilter(\'all\')"> Show All </button>')
453 doc.add_line('<button class ="button buttonOK" '
454 'onclick="buttonFilter(\'ok\')" > OK </button>')
456 doc.add_line('<button class ="button buttonMissing" '
457 'onclick="buttonFilter(\'missing\')" > Missing </button>')
459 doc.add_line('<button class ="button buttonPartial" '
460 'onclick="buttonFilter(\'partial\')" > Partial </button>')
462 doc.add_line('<button class ="button buttonJustified" '
463 'onclick="buttonFilter(\'justified\')" > Justified </button>')
465 doc.add_line('<button class ="button buttonWarning" '
466 'onclick="buttonFilter(\'warning\')" > Warning </button>')
467 doc.add_line("</div>")
469 doc.add_heading(3, "Show Issues", html_identifier=True)
470 doc.add_line('<div id = "ContainerBtnToggleIssue">')
471 doc.add_line('<button class ="button buttonBlue" id="BtnToggleIssue" '
472 'onclick="ToggleIssues()"> Show Issues </button>')
473 doc.add_line('</div>')
475 doc.add_heading(3, "Filter", "filter", html_identifier=True)
476 doc.add_line('<input type="text" id="search" placeholder="Filter..." '
477 'onkeyup="searchItem()">')
478 doc.add_line('<div id="search-sec-id"')
480 ### Issues
481 doc.add_heading(2, "Issues", "issues", html_identifier=True)
482 doc.add_line('<div id="issues-section" style="display:none">')
483 has_issues = False
484 for item in sorted(report.items.values(),
485 key = lambda x: x.location.sorting_key()):
486 if item.tracing_status not in (Tracing_Status.OK,
487 Tracing_Status.JUSTIFIED):
488 for message in item.messages:
489 if not has_issues:
490 has_issues = True
491 doc.add_line("<ul>")
492 doc.add_line(
493 f'<li class="issue issue-{item.tracing_status.name.lower()}'
494 f' issue-{item.tracing_status.name.lower()}-'
495 f'{item.tag.namespace}">{xref_item(item)}: {message}</li>'
496 )
497 if has_issues:
498 doc.add_line("</ul>")
499 else:
500 doc.add_line("<div>No traceability issues found.</div>")
501 doc.add_line("</div>")
503 ### Report
504 file_heading = None
505 doc.add_heading(2, "Detailed report", "detailed-report", html_identifier=True)
506 items_by_level = {}
507 for level in report.config:
508 items_by_level[level] = [item
509 for item in report.items.values()
510 if item.level == level]
511 for kind, title in [("requirements",
512 "Requirements and Specification"),
513 ("implementation",
514 "Implementation"),
515 ("activity",
516 "Verification and Validation")]:
517 doc.add_line(f'<div class="detailed-report-{title.lower().replace(" ", "-")}">')
518 doc.add_heading(3, title, html_identifier=True)
519 for level in report.config.values():
520 if level.kind != kind:
521 continue
522 doc.add_line(f'<div id="section-{level.name.lower().replace(" ", "-")}">')
523 doc.add_heading(4,
524 html.escape(level.name),
525 name_hash(level.name),
526 html_identifier=True,
527 )
528 if items_by_level[level.name]: 528 ↛ 567line 528 didn't jump to line 567 because the condition on line 528 was always true
529 for item in sorted(items_by_level[level.name],
530 key = lambda x: x.location.sorting_key()):
531 if isinstance(item.location, Void_Reference): 531 ↛ 532line 531 didn't jump to line 532 because the condition on line 531 was never true
532 new_file_heading = "Unknown"
533 elif isinstance(item.location, (File_Reference,
534 Github_Reference)):
535 new_file_heading = item.location.filename
536 elif isinstance(item.location, Codebeamer_Reference):
537 new_file_heading = (
538 f"Codebeamer {item.location.cb_root},"
539 f" tracker {item.location.tracker}"
540 )
541 else: # pragma: no cover
542 assert False
543 if new_file_heading != file_heading:
544 file_heading = new_file_heading
545 doc.add_heading(5, html.escape(file_heading))
547 write_item_box_begin(doc, item, report)
548 if isinstance(item, Requirement) and item.status:
549 doc.add_line('<div class="attribute">')
550 doc.add_line(f"Status: {html.escape(item.status)}")
551 doc.add_line('</div>')
552 if (isinstance(item, (Requirement, Activity)) and item.text):
553 if render_md:
554 bq_class = ' class="md_description"'
555 bq_text = markdown.markdown(item.text,
556 extensions=['tables'])
557 else:
558 bq_class = ""
559 bq_text = html.escape(item.text).replace("\n", "<br>")
561 doc.add_line('<div class="attribute">')
562 doc.add_line(f"<blockquote{bq_class}>{bq_text}</blockquote>")
563 doc.add_line('</div>')
564 write_item_tracing(doc, report, item)
565 write_item_box_end(doc, item)
566 else:
567 doc.add_line("No items recorded at this level.")
568 doc.add_line("</div>") # Closing tag for id #level.name
569 doc.add_line("</div>") # Closing tag for detailed-report-<title>
570 # Closing tag for id #search-sec-id
571 doc.add_line("</div>")
572 # Add LOBSTER version in the footer.
573 doc.add_line("<footer>")
574 doc.add_line(f"<p>LOBSTER Version: {LOBSTER_VERSION}</p>")
575 doc.add_line("</footer>")
577 # Add the css from assets
578 doc.css.append(CSS.lstrip())
580 # Add javascript from assets/html_report.js file
581 doc.scripts.append(JAVA_SCRIPT.lstrip())
583 return doc.render()
586class HtmlReportTool(MetaDataToolBase):
587 def __init__(self):
588 super().__init__(
589 name="html-report",
590 description="Visualise LOBSTER report in HTML",
591 official=True,
592 )
594 ap = self._argument_parser
595 ap.add_argument("lobster_report",
596 nargs="?",
597 default="report.lobster")
598 ap.add_argument("--out",
599 default="lobster_report.html")
600 ap.add_argument("--dot",
601 help="path to dot utility (https://graphviz.org), \
602 by default expected in PATH",
603 default=None)
604 ap.add_argument("--high-contrast",
605 action="store_true",
606 help="Uses a color palette with a higher contrast.")
607 ap.add_argument("--render-md",
608 action="store_true",
609 help="Renders MD in description.")
610 ap.add_argument("--source-root",
611 default="",
612 help="Prefix to prepend to file reference links, "
613 "e.g. a path from the HTML output location "
614 "back to the workspace root.")
616 def _run_impl(self, options: argparse.Namespace) -> int:
617 if not os.path.isfile(options.lobster_report):
618 self._argument_parser.error(f"{options.lobster_report} is not a file")
620 report = Report()
621 report.load_report(options.lobster_report)
622 report.source_root = options.source_root
624 html_content = write_html(
625 report = report,
626 dot = options.dot,
627 high_contrast = options.high_contrast,
628 render_md = options.render_md,
629 )
630 write_html_to_file(html_content, options.out)
631 print(f"LOBSTER HTML report written to {options.out}")
633 return 0
636def lobster_html_report(
637 lobster_report_path: str,
638 output_html_path: str,
639 dot_path: str = None,
640 high_contrast: bool = False,
641 render_md: bool = False,
642 source_root: str = "",
643) -> None:
644 """
645 API function to generate an HTML report from a LOBSTER report file.
647 Args:
648 lobster_report_path (str): Path to the input LOBSTER report file.
649 output_html_path (str): Path to the output HTML file.
650 dot_path (str, optional): Path to the Graphviz 'dot' utility.
651 high_contrast (bool, optional): Use high contrast colors.
652 render_md (bool, optional): Render Markdown in descriptions.
653 source_root (str, optional): Prefix to prepend to file reference links.
654 """
655 report = Report()
656 report.load_report(lobster_report_path)
657 report.source_root = source_root
658 html_content = write_html(
659 report=report,
660 dot=dot_path,
661 high_contrast=high_contrast,
662 render_md=render_md,
663 )
664 write_html_to_file(html_content, output_html_path)
667def main(args: Optional[Sequence[str]] = None) -> int:
668 return HtmlReportTool().run(args)