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