Coverage for lobster/tools/core/html_report/html_report.py: 12%
332 statements
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-06 09:51 +0000
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-06 09:51 +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
25import sys
26from datetime import datetime, timezone
28import markdown
30from lobster.html import htmldoc
31from lobster.report import Report
32from lobster.location import (Void_Reference,
33 File_Reference,
34 Github_Reference,
35 Codebeamer_Reference)
36from lobster.items import (Tracing_Status, Item,
37 Requirement, Implementation, Activity)
38from lobster.meta_data_tool_base import MetaDataToolBase
40LOBSTER_GH = "https://github.com/bmw-software-engineering/lobster"
43def is_dot_available(dot):
44 try:
45 subprocess.run([dot if dot else "dot", "-V"],
46 stdout=subprocess.PIPE,
47 stderr=subprocess.PIPE,
48 encoding="UTF-8",
49 check=True)
50 return True
51 except FileNotFoundError:
52 return False
55def name_hash(name):
56 hobj = hashlib.md5()
57 hobj.update(name.encode("UTF-8"))
58 return hobj.hexdigest()
61def xref_item(item, link=True, brief=False):
62 assert isinstance(item, Item)
63 assert isinstance(link, bool)
64 assert isinstance(brief, bool)
66 if brief:
67 rv = ""
68 elif isinstance(item, Requirement):
69 rv = html.escape(item.framework + " " +
70 item.kind.capitalize())
71 elif isinstance(item, Implementation):
72 rv = html.escape(item.language + " " +
73 item.kind.capitalize())
74 else:
75 assert isinstance(item, Activity)
76 rv = html.escape(item.framework + " " +
77 item.kind.capitalize())
78 if not brief:
79 rv += " "
81 if link:
82 rv += f"<a href='#item-{item.tag.hash()}'>{html.escape(item.name)}</a>"
83 else:
84 rv += html.escape(item.name)
86 return rv
89def create_policy_diagram(doc, report, dot):
90 assert isinstance(doc, htmldoc.Document)
91 assert isinstance(report, Report)
93 graph = 'digraph "LOBSTER Tracing Policy" {\n'
94 for level in report.config.values():
95 if level["kind"] == "requirements":
96 style = 'shape=box, style=rounded'
97 elif level["kind"] == "implementation":
98 style = 'shape=box'
99 else:
100 assert level["kind"] == "activity"
101 style = 'shape=hexagon'
102 style += f', href="#sec-{name_hash(level["name"])}"'
104 graph += ' n_%s [label="%s", %s];\n' % \
105 (name_hash(level["name"]),
106 level["name"],
107 style)
109 for level in report.config.values():
110 source = name_hash(level["name"])
111 for target in map(name_hash, level["traces"]):
112 # Not a mistake; we want to show the tracing down, whereas
113 # in the config file we indicate how we trace up.
114 graph += ' n_%s -> n_%s;\n' % (target, source)
115 graph += "}\n"
117 with tempfile.TemporaryDirectory() as tmp_dir:
118 graph_name = os.path.join(tmp_dir, "graph.dot")
119 with open(graph_name, "w", encoding="UTF-8") as tmp_fd:
120 tmp_fd.write(graph)
121 svg = subprocess.run([dot if dot else "dot", "-Tsvg", graph_name],
122 stdout=subprocess.PIPE,
123 encoding="UTF-8",
124 check=True)
125 assert svg.returncode == 0
126 image = svg.stdout[svg.stdout.index("<svg "):]
128 for line in image.splitlines():
129 doc.add_line(line)
132def create_item_coverage(doc, report):
133 assert isinstance(doc, htmldoc.Document)
134 assert isinstance(report, Report)
136 doc.add_line("<table>")
137 doc.add_line("<thead><tr>")
138 doc.add_line("<td>Category</td>")
139 doc.add_line("<td>Ratio</td>")
140 doc.add_line("<td>Coverage</td>")
141 doc.add_line("<td>OK Items</td>")
142 doc.add_line("<td>Total Items</td>")
143 doc.add_line("</tr><thead>")
144 doc.add_line("<tbody>")
145 doc.add_line("</tbody>")
146 for level in report.config.values():
147 data = report.coverage[level["name"]]
148 doc.add_line(
149 f'<tr class="coverage-table-{level["name"].replace(" ", "-").lower()}">'
150 )
151 doc.add_line('<td><a href="#sec-%s">%s</a></td>' %
152 (name_hash(level["name"]),
153 html.escape(level["name"])))
154 doc.add_line("<td>%.1f%%</td>" % data.coverage)
155 doc.add_line("<td>")
156 doc.add_line('<progress value="%u" max="%u">' %
157 (data.ok, data.items))
158 doc.add_line("%.2f%%" % data.coverage)
159 doc.add_line('</progress>')
160 doc.add_line("</td>")
161 doc.add_line('<td align="right">%u</td>' % data.ok)
162 doc.add_line('<td align="right">%u</td>' % data.items)
163 doc.add_line("</tr>")
164 doc.add_line("</table>")
167def run_git_show(commit_hash, path=None):
168 """Run `git show` command to get the commit timestamp."""
169 cmd = ['git'] + (['-C', path] if path else []) + [
170 'show', '-s', '--format=%ct', commit_hash]
171 try:
172 output = subprocess.run(cmd, capture_output=True, text=True, check=True)
173 if output.stdout.strip(): 173 ↛ 179line 173 didn't jump to line 179 because the condition on line 173 was always true
174 epoch = int(output.stdout.strip())
175 return str(datetime.fromtimestamp(epoch, tz=timezone.utc)) + " UTC"
176 except subprocess.CalledProcessError:
177 location = f"submodule path: {path}" if path else "main repository"
178 print(f"[Warning] Could not find commit {commit_hash} in {location}.")
179 return None
182def get_commit_timestamp_utc(commit_hash, submodule_path=None):
183 """Get commit timestamp in UTC format, either from main repo or submodule."""
184 timestamp = run_git_show(commit_hash)
185 if timestamp:
186 return f"{timestamp}"
188 if submodule_path: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 timestamp = run_git_show(commit_hash, submodule_path)
190 if timestamp:
191 return f"{timestamp} (from submodule at {submodule_path})"
193 return "Unknown"
196def write_item_box_begin(doc, item):
197 assert isinstance(doc, htmldoc.Document)
198 assert isinstance(item, Item)
200 doc.add_line(f'<!-- begin item {html.escape(item.tag.key())} -->')
202 doc.add_line(f'<div class="item-{html.escape(item.tracing_status.name.lower())}" '
203 f'id="item-{item.tag.hash()}">')
205 doc.add_line('<div class="item-name">%s %s</div>' %
206 ('<svg class="icon"><use href="#svg-check-square"></use></svg>'
207 if item.tracing_status in (Tracing_Status.OK,
208 Tracing_Status.JUSTIFIED)
209 else '<svg class="icon"><use href="#svg-alert-triangle"></use></svg>',
210 xref_item(item, link=False)))
212 doc.add_line('<div class="attribute">Source: ')
213 doc.add_line('<svg class="icon"><use href="#svg-external-link"></use></svg>')
215 doc.add_line(item.location.to_html())
216 doc.add_line("</div>")
219def write_item_tracing(doc, report, item):
220 assert isinstance(doc, htmldoc.Document)
221 assert isinstance(report, Report)
222 assert isinstance(item, Item)
224 doc.add_line('<div class="attribute">')
225 if item.ref_down:
226 doc.add_line("<div>Traces to:")
227 doc.add_line("<ul>")
228 for ref in item.ref_down:
229 doc.add_line("<li>%s</li>" % xref_item(report.items[ref.key()]))
230 doc.add_line("</ul>")
231 doc.add_line("</div>")
232 if item.ref_up:
233 doc.add_line("<div>Derived from:")
234 doc.add_line("<ul>")
235 for ref in item.ref_up:
236 doc.add_line("<li>%s</li>" % xref_item(report.items[ref.key()]))
237 doc.add_line("</ul>")
238 doc.add_line("</div>")
240 if item.tracing_status == Tracing_Status.JUSTIFIED:
241 doc.add_line("<div>Justifications:")
242 doc.add_line("<ul>")
243 for msg in item.just_global + item.just_up + item.just_down:
244 doc.add_line("<li>%s</li>" % html.escape(msg))
245 doc.add_line("</ul>")
246 doc.add_line("</div>")
248 if item.messages:
249 doc.add_line("<div>Issues:")
250 doc.add_line("<ul>")
251 for msg in item.messages:
252 doc.add_line("<li>%s</li>" % html.escape(msg))
253 doc.add_line("</ul>")
254 doc.add_line("</div>")
256 doc.add_line("</div>")
259def write_item_box_end(doc, item):
260 assert isinstance(doc, htmldoc.Document)
262 if getattr(item.location, "commit", None) is not None:
263 commit_hash = item.location.commit
264 timestamp = get_commit_timestamp_utc(commit_hash, item.location.gh_repo)
265 doc.add_line(
266 f'<div class="attribute">'
267 f'Build Reference: <strong>{commit_hash}</strong> | '
268 f'Timestamp: {timestamp}'
269 f'</div>'
270 )
271 doc.add_line("</div>")
272 doc.add_line('<!-- end item -->')
275def generate_custom_data(report) -> str:
276 content = [
277 f"{key}: {value}<br>"
278 for key, value in report.custom_data.items()
279 if value
280 ]
281 return "".join(content)
284def write_html(fd, report, dot, high_contrast, render_md):
285 assert isinstance(report, Report)
287 doc = htmldoc.Document(
288 "L.O.B.S.T.E.R.",
289 "Lightweight Open BMW Software Traceability Evidence Report"
290 )
292 # Item styles
293 doc.style["#custom-data-banner"] = {
294 "position": "absolute",
295 "top": "1em",
296 "right": "2em",
297 "font-size": "0.9em",
298 "color": "white",
299 }
300 doc.style[".item-ok, .item-partial, .item-missing, .item-justified"] = {
301 "border" : "1px solid black",
302 "border-radius" : "0.5em",
303 "margin-top" : "0.4em",
304 "padding" : "0.25em",
305 }
306 doc.style[".item-ok:target, "
307 ".item-partial:target, "
308 ".item-missing:target, "
309 ".item-justified:target"] = {
310 "border" : "3px solid black",
311 }
312 doc.style[".subtle-ok, "
313 ".subtle-partial, "
314 ".subtle-missing, "
315 ".subtle-justified"] = {
316 "padding-left" : "0.2em",
317 }
318 doc.style[".item-ok"] = {
319 "background-color" : "#b2e1b2" if high_contrast else "#efe",
320 }
321 doc.style[".item-partial"] = {
322 "background-color" : "#ffe",
323 }
324 doc.style[".item-missing"] = {
325 "background-color" : "#ffb2ff" if high_contrast else "#fee",
326 }
327 doc.style[".item-justified"] = {
328 "background-color" : "#eee",
329 }
330 doc.style[".subtle-ok"] = {
331 "border-left" : "0.2em solid #8f8",
332 }
333 doc.style[".subtle-partial"] = {
334 "border-left" : "0.2em solid #ff8",
335 }
336 doc.style[".subtle-missing"] = {
337 "border-left" : "0.2em solid #f88",
338 }
339 doc.style[".subtle-justified"] = {
340 "border-left" : "0.2em solid #888",
341 }
342 doc.style[".item-name"] = {
343 "font-size" : "125%",
344 "font-weight" : "bold",
345 }
346 doc.style[".attribute"] = {
347 "margin-top" : "0.5em",
348 }
350 # Render MD
351 if render_md:
352 doc.style[".md_description"] = {
353 "font-style" : "unset",
354 }
355 doc.style[".md_description h1"] = {
356 "padding" : "unset",
357 "margin" : "unset"
358 }
359 doc.style[".md_description h2"] = {
360 "padding" : "unset",
361 "margin" : "unset",
362 "border-bottom" : "unset",
363 "text-align" : "unset"
364 }
366 # Columns
367 doc.style[".columns"] = {
368 "display" : "flex",
369 }
370 doc.style[".columns .column"] = {
371 "flex" : "45%",
372 }
374 # Tables
375 doc.style["thead tr"] = {
376 "font-weight" : "bold",
377 }
378 doc.style["tbody tr.alt"] = {
379 "background-color" : "#eee",
380 }
382 # Text
383 doc.style["blockquote"] = {
384 "font-style" : "italic",
385 "border-left" : "0.2em solid gray",
386 "padding-left" : "0.4em",
387 "margin-left" : "0.5em",
388 }
390 ### Menu & Navigation
391 doc.navbar.add_link("Overview", "#sec-overview")
392 doc.navbar.add_link("Issues", "#sec-issues")
393 menu = doc.navbar.add_dropdown("Detailed report")
394 for level in report.config.values():
395 menu.add_link(level["name"], "#sec-" + name_hash(level["name"]))
396 # doc.navbar.add_link("Software Traceability Matrix", "#matrix")
397 if report.custom_data:
398 content = generate_custom_data(report)
399 doc.add_line(f'<div id="custom-data-banner">{content}</div>')
400 menu = doc.navbar.add_dropdown("LOBSTER", "right")
401 menu.add_link("Documentation",
402 "%s/blob/main/README.md" % LOBSTER_GH)
403 menu.add_link("License",
404 "%s/blob/main/LICENSE.md" % LOBSTER_GH)
405 menu.add_link("Source", LOBSTER_GH)
407 ### Summary (Coverage & Policy)
408 doc.add_heading(2, "Overview", "overview", html_identifier=True)
409 doc.add_line('<div class="columns">')
410 doc.add_line('<div class="column">')
411 doc.add_heading(3, "Coverage", html_identifier=True)
412 create_item_coverage(doc, report)
413 doc.add_line('</div>')
414 if is_dot_available(dot):
415 doc.add_line('<div class="column">')
416 doc.add_heading(3, "Tracing policy")
417 create_policy_diagram(doc, report, dot)
418 doc.add_line('</div>')
419 else:
420 print("warning: dot utility not found, report will not "
421 "include the tracing policy visualisation")
422 print("> please install Graphviz (https://graphviz.org)")
423 doc.add_line('</div>')
425 ### Filtering
426 doc.add_heading(2, "Filtering", "filtering-options", html_identifier=True)
427 doc.add_heading(3, "Item Filters", html_identifier=True)
428 doc.add_line('<div id = "btnFilterItem">')
429 doc.add_line('<button class="button buttonAll buttonActive" '
430 'onclick="buttonFilter(\'all\')"> Show All </button>')
432 doc.add_line('<button class ="button buttonOK" '
433 'onclick="buttonFilter(\'ok\')" > OK </button>')
435 doc.add_line('<button class ="button buttonMissing" '
436 'onclick="buttonFilter(\'missing\')" > Missing </button>')
438 doc.add_line('<button class ="button buttonPartial" '
439 'onclick="buttonFilter(\'partial\')" > Partial </button>')
441 doc.add_line('<button class ="button buttonJustified" '
442 'onclick="buttonFilter(\'justified\')" > Justified </button>')
444 doc.add_line('<button class ="button buttonWarning" '
445 'onclick="buttonFilter(\'warning\')" > Warning </button>')
446 doc.add_line("</div>")
448 doc.add_heading(3, "Show Issues", html_identifier=True)
449 doc.add_line('<div id = "ContainerBtnToggleIssue">')
450 doc.add_line('<button class ="button buttonBlue" id="BtnToggleIssue" '
451 'onclick="ToggleIssues()"> Show Issues </button>')
452 doc.add_line('</div>')
454 doc.add_heading(3, "Filter", "filter", html_identifier=True)
455 doc.add_line('<input type="text" id="search" placeholder="Filter..." '
456 'onkeyup="searchItem()">')
457 doc.add_line('<div id="search-sec-id"')
459 ### Issues
460 doc.add_heading(2, "Issues", "issues", html_identifier=True)
461 doc.add_line('<div id="issues-section" style="display:none">')
462 has_issues = False
463 for item in sorted(report.items.values(),
464 key = lambda x: x.location.sorting_key()):
465 if item.tracing_status not in (Tracing_Status.OK,
466 Tracing_Status.JUSTIFIED):
467 for message in item.messages:
468 if not has_issues:
469 has_issues = True
470 doc.add_line("<ul>")
471 doc.add_line(
472 f'<li class="issue issue-{item.tracing_status.name.lower()}-'
473 f'{item.tag.namespace}">{xref_item(item)}: {message}</li>'
474 )
475 if has_issues:
476 doc.add_line("</ul>")
477 else:
478 doc.add_line("<div>No traceability issues found.</div>")
479 doc.add_line("</div>")
481 ### Report
482 file_heading = None
483 doc.add_heading(2, "Detailed report", "detailed-report", html_identifier=True)
484 items_by_level = {}
485 for level in report.config:
486 items_by_level[level] = [item
487 for item in report.items.values()
488 if item.level == level]
489 for kind, title in [("requirements",
490 "Requirements and Specification"),
491 ("implementation",
492 "Implementation"),
493 ("activity",
494 "Verification and Validation")]:
495 doc.add_line(f'<div class="detailed-report-{title.lower().replace(" ", "-")}">')
496 doc.add_heading(3, title, html_identifier=True)
497 for level in report.config.values():
498 if level["kind"] != kind:
499 continue
500 doc.add_heading(4,
501 html.escape(level["name"]),
502 name_hash(level["name"]),
503 html_identifier=True,
504 )
505 if items_by_level[level["name"]]:
506 for item in sorted(items_by_level[level["name"]],
507 key = lambda x: x.location.sorting_key()):
508 if isinstance(item.location, Void_Reference):
509 new_file_heading = "Unknown"
510 elif isinstance(item.location, (File_Reference,
511 Github_Reference)):
512 new_file_heading = item.location.filename
513 elif isinstance(item.location, Codebeamer_Reference):
514 new_file_heading = "Codebeamer %s, tracker %u" % \
515 (item.location.cb_root,
516 item.location.tracker)
517 else: # pragma: no cover
518 assert False
519 if new_file_heading != file_heading:
520 file_heading = new_file_heading
521 doc.add_heading(5, html.escape(file_heading),
522 html_identifier=True)
524 write_item_box_begin(doc, item)
525 if isinstance(item, Requirement) and item.status:
526 doc.add_line('<div class="attribute">')
527 doc.add_line("Status: %s" % html.escape(item.status))
528 doc.add_line('</div>')
529 if isinstance(item, Requirement) and item.text:
530 if render_md:
531 bq_class = ' class="md_description"'
532 bq_text = markdown.markdown(item.text,
533 extensions=['tables'])
534 else:
535 bq_class = ""
536 bq_text = html.escape(item.text).replace("\n", "<br>")
538 doc.add_line('<div class="attribute">')
539 doc.add_line(f"<blockquote{bq_class}>{bq_text}</blockquote>")
540 doc.add_line('</div>')
541 write_item_tracing(doc, report, item)
542 write_item_box_end(doc, item)
543 else:
544 doc.add_line("No items recorded at this level.")
545 doc.add_line("</div>") # Closing tag for detailed-report-<title>
546 # Closing tag for id #search-sec-id
547 doc.add_line("</div>")
549 # Add the css from assets
550 dir_path = os.path.dirname(os.path.abspath(__file__))
551 file_path = dir_path + "/assets"
552 for filename in os.listdir(file_path):
553 if filename.endswith(".css"):
554 filename = os.path.join(file_path, filename)
555 with open(filename, "r", encoding="UTF-8") as styles:
556 doc.css.append("".join(styles.readlines()))
558 # Add javascript from assets/html_report.js file
559 dir_path = os.path.dirname(os.path.abspath(__file__))
560 file_path = dir_path + "/assets"
561 for filename in os.listdir(file_path):
562 if filename.endswith(".js"):
563 filename = os.path.join(file_path, filename)
564 with open(filename, "r", encoding="UTF-8") as scripts:
565 doc.scripts.append("".join(scripts.readlines()))
567 fd.write(doc.render() + "\n")
570class HtmlReportTool(MetaDataToolBase):
571 def __init__(self):
572 super().__init__(
573 name="html-report",
574 description="Visualise LOBSTER report in HTML",
575 official=True,
576 )
578 ap = self._argument_parser
579 ap.add_argument("lobster_report",
580 nargs="?",
581 default="report.lobster")
582 ap.add_argument("--out",
583 default="lobster_report.html")
584 ap.add_argument("--dot",
585 help="path to dot utility (https://graphviz.org), \
586 by default expected in PATH",
587 default=None)
588 ap.add_argument("--high-contrast",
589 action="store_true",
590 help="Uses a color palette with a higher contrast.")
591 ap.add_argument("--render-md",
592 action="store_true",
593 help="Renders MD in description.")
595 def _run_impl(self, options: argparse.Namespace) -> int:
596 if not os.path.isfile(options.lobster_report):
597 self._argument_parser.error(f"{options.lobster_report} is not a file")
599 report = Report()
600 report.load_report(options.lobster_report)
602 with open(options.out, "w", encoding="UTF-8") as fd:
603 write_html(
604 fd = fd,
605 report = report,
606 dot = options.dot,
607 high_contrast = options.high_contrast,
608 render_md = options.render_md,
609 )
610 print("LOBSTER HTML report written to %s" % options.out)
612 return 0
615def main() -> int:
616 return HtmlReportTool().run()
619if __name__ == "__main__":
620 sys.exit(main())