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