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