# SPDX-FileCopyrightText: 2025 SPDX contributors
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: Apache-2.0
"""
Report generation functionality.
Some of the code here was originally in the BaseChecker class.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from .constants import (
SUPPORTED_COMPLIANCE_STANDARDS,
SUPPORTED_COMPLIANCE_STANDARDS_DESC,
)
if TYPE_CHECKING:
from spdx_tools.spdx.validation.validation_message import ValidationMessage
# pylint: disable=too-many-instance-attributes
[docs]
@dataclass
class ReportContext:
"""Context for generating conformance reports."""
sbom_spec: str = ""
compliance_standard: str = ""
compliant: bool = False
requirement_results: list[tuple[str, bool]] | None = None
components_without_info: list[tuple[str, list[tuple[str, str]]]] | None = None
validation_messages: list[ValidationMessage] | None = None
conformance_messages: list[ValidationMessage] | None = None
parsing_errors: list[str] | None = None
unknown_pointer_edges: dict[str, list[str]] | None = None
floating_component_ids: set[str] | None = None
def _safe_attr(obj: object, name: str) -> str:
val = getattr(obj, name, None)
return str(val) if val not in (None, "") else "N/A"
[docs]
def print_validation_messages(
validation_messages: list[ValidationMessage], verbose: bool = False
) -> None:
"""Prints validation messages and optional context details.
Args:
validation_messages (list[ValidationMessage]): List of validation messages.
verbose (bool): If True, include detailed validation context.
Returns:
None
"""
print(get_validation_messages_text(validation_messages, verbose))
[docs]
def get_validation_messages_text(
validation_messages: list[ValidationMessage], verbose: bool = False
) -> str:
"""Generates validation messages and optional context details.
Args:
validation_messages (list[ValidationMessage]): List of validation messages.
verbose (bool): If True, include detailed validation context.
Returns:
str: Plain-text representation of the validation messages.
"""
report: list[str] = []
for msg in validation_messages:
if not msg.validation_message:
continue
report.append(msg.validation_message)
if verbose and getattr(msg, "context", None):
ctx = msg.context
report.append(f"- SPDX ID: {_safe_attr(ctx, 'spdx_id')}")
report.append(f"- Parent ID: {_safe_attr(ctx, 'parent_id')}")
report.append(f"- Element type: {_safe_attr(ctx, 'element_type')}")
report.append("")
return "\n".join(report)
[docs]
def get_validation_messages_html(
validation_messages: list[ValidationMessage], verbose: bool = False
) -> str:
"""Generates HTML for validation messages and context details.
Args:
validation_messages (list[ValidationMessage]): List of validation messages.
verbose (bool): If True, include detailed validation context.
Returns:
str: HTML representation of the validation messages.
"""
if not validation_messages:
return ""
html = "<ul class='conformance-val-list'>\n"
for msg in validation_messages:
if not getattr(msg, "validation_message", None):
continue
html += "<li>\n"
html += "<p class='conformance-val-msg-label'>Validation message:</p>\n"
html += f"<p class='conformance-val-msg'>{msg.validation_message}</p>\n"
if verbose and getattr(msg, "context", None):
ctx = msg.context
if ctx:
html += "<p class='conformance-val-ctx-label'>Validation context:</p>\n"
html += "<ul class='conformance-val-ctx'>\n"
html += f"<li>SPDX ID: {_safe_attr(ctx, 'spdx_id')}</li>\n"
html += f"<li>Parent ID: {_safe_attr(ctx, 'parent_id')}</li>\n"
html += f"<li>Element type: {_safe_attr(ctx, 'element_type')}</li>\n"
html += "</ul>\n"
html += "</li>\n"
html += "</ul>"
return html
[docs]
def get_validation_messages_json(
validation_messages: list[ValidationMessage],
) -> list[dict[str, str]]:
"""Generates JSON-serializable list for validation messages and context details.
Args:
validation_messages (list[ValidationMessage]): List of validation messages.
Returns:
list[dict[str, str]]: JSON-serializable representation of the validation messages.
"""
json_output: list[dict[str, str]] = []
for msg in validation_messages:
if not getattr(msg, "validation_message", None):
continue
val_msg = {"message": msg.validation_message}
if getattr(msg, "context", None):
ctx = msg.context
val_msg["spdxId"] = str(getattr(ctx, "spdx_id", ""))
val_msg["parentId"] = str(getattr(ctx, "parent_id", ""))
val_msg["elementType"] = str(getattr(ctx, "element_type", ""))
json_output.append(val_msg)
return json_output
def _generate_graph_text_report(rc: ReportContext) -> list[str]:
"""Generates a text report for graph structural issues."""
graph_issues_report: list[str] = []
if not rc.unknown_pointer_edges and not rc.floating_component_ids:
return graph_issues_report
graph_issues_report.append("\n" + "=" * 55)
graph_issues_report.append("Structural Graph Issues ".center(55))
graph_issues_report.append("=" * 55)
if rc.unknown_pointer_edges:
graph_issues_report.append("\n************* ERROR *************")
graph_issues_report.append(
"Unknown components detected! A relationship points to a missing element."
)
graph_issues_report.append(" -> Broken dependency linkages found:")
for source, targets in rc.unknown_pointer_edges.items():
for target in targets:
graph_issues_report.append(
f" * Component '{source}' links to missing element '{target}'"
)
if rc.floating_component_ids:
floating_ids = sorted(rc.floating_component_ids)
graph_issues_report.append("\n************ WARNING ************")
graph_issues_report.append(
f"Found {len(floating_ids)} disconnected 'floating' elements."
)
graph_issues_report.append(
" -> These elements are not attached to the primary software tree"
)
graph_issues_report.append(
" and were ignored during the compliance check.\n"
)
for spdx_id in floating_ids[:10]:
graph_issues_report.append(f" * {spdx_id}")
if len(floating_ids) > 10:
graph_issues_report.append(
f" * ... and {len(floating_ids) - 10} more items."
)
graph_issues_report.append("\n" + "-" * 55)
return graph_issues_report
[docs]
def report_text(
rc: ReportContext,
verbose: bool = False,
) -> str:
"""Generates element-by-element result table in plain-text.
Args:
rc (ReportContext): Information for generating the report.
verbose (bool): If True, include detailed validation messages.
Returns:
str: Plain-text representation of the results.
"""
report: list[str] = []
# Parsing error
if rc.parsing_errors:
report.append("The document couldn't be parsed; check couldn't be performed.\n")
if rc.parsing_errors:
report.append("The following parsing error(s) were raised:\n")
for error in rc.parsing_errors:
report.append(error)
return "\n".join(report)
# Unsupported compliance standard
if rc.compliance_standard not in SUPPORTED_COMPLIANCE_STANDARDS:
report.append(f"Unsupported compliance standard {rc.compliance_standard!r}")
return "\n".join(report)
# Compliance results
report.append(
f"{SUPPORTED_COMPLIANCE_STANDARDS_DESC[rc.compliance_standard]}"
" Conformance Results\n"
)
report.append(f"Conformant: {rc.compliant}\n")
if rc.requirement_results:
report.append("Requirement | Status")
report.append("-------------------------------------------------------")
for label, value in rc.requirement_results:
report.append(f"{label:<46} | {value}")
report.append("")
if rc.validation_messages:
report.append(
"The document is not valid according to the SBOM "
f'specification ("{rc.sbom_spec}"). '
"The following violations were found:\n"
)
report.append(get_validation_messages_text(rc.validation_messages, verbose))
if rc.conformance_messages:
report.append("The following conformance issues were found:\n")
report.append(get_validation_messages_text(rc.conformance_messages, verbose))
# structural graph issues
graph_issues_report = _generate_graph_text_report(rc)
if graph_issues_report:
report.extend(graph_issues_report)
return "\n".join(report)
def _generate_graph_html_report(rc: ReportContext) -> list[str]:
"""Generates an HTML report for graph structural issues."""
graph_issues_report: list[str] = []
if not rc.unknown_pointer_edges and not rc.floating_component_ids:
return graph_issues_report
graph_issues_report.append("<div class='conformance-graph'>")
graph_issues_report.append(
"<h2 class='conformance-res-title'>Structural Graph Issues</h2>"
)
if rc.unknown_pointer_edges:
graph_issues_report.append(
"<p class='conformance-err-label'><strong>ERROR:</strong>"
" Unknown components detected! A relationship points to a missing element.</p>"
)
graph_issues_report.append("<ul class='conformance-err-list'>")
for source, targets in rc.unknown_pointer_edges.items():
for target in targets:
graph_issues_report.append(
f"<li>Component '<b>{source}</b>' links to missing element "
f"'<b>{target}</b>'</li>"
)
graph_issues_report.append("</ul>")
if rc.floating_component_ids:
floating_ids = sorted(rc.floating_component_ids)
graph_issues_report.append(
"<p class='conformance-msg-label' style='margin-top: 15px;'>"
f"<strong>WARNING:</strong> Found {len(floating_ids)} disconnected "
"'floating' elements ignored during compliance.</p>"
)
graph_issues_report.append("<ul class='conformance-msg-list'>")
for spdx_id in floating_ids[:10]:
graph_issues_report.append(f"<li>{spdx_id}</li>")
if len(floating_ids) > 10:
graph_issues_report.append(
f"<li>... and {len(floating_ids) - 10} more items.</li>"
)
graph_issues_report.append("</ul>")
graph_issues_report.append("</div>")
return graph_issues_report
[docs]
def report_html(
rc: ReportContext,
verbose: bool = False,
) -> str:
"""Generates element-by-element result table in HTML.
Args:
rc (ReportContext): Information for generating the report.
verbose (bool): If True, include detailed validation messages.
Returns:
str: HTML representation of the results.
"""
report: list[str] = []
# Parsing error
if rc.parsing_errors:
report.append("<div class='conformance-err'>")
report.append(
"<p class='conformance-err-label'>"
"The document couldn't be parsed; check couldn't be performed.<br />"
"The following parsing errors were raised:"
"</p>"
)
report.append("<ul class='conformance-err-list'>")
for err in rc.parsing_errors:
report.append(f"<li>{err}</li>")
report.append("</ul>")
report.append("</div>")
return "\n".join(report)
# Unsupported compliance standard
if rc.compliance_standard not in SUPPORTED_COMPLIANCE_STANDARDS:
report.append(
"<div class='conformance-err'>"
"<p class='conformance-err-label'>"
f"Unsupported compliance standard {rc.compliance_standard!r}"
"</p>"
"</div>"
)
return "\n".join(report)
# Compliance results
report.append("<div class='conformance-res'>")
report.append(
"<h2 class='conformance-res-title'>"
f"{SUPPORTED_COMPLIANCE_STANDARDS_DESC[rc.compliance_standard]}"
" Conformance Results</h2>"
)
report.append(f"<h3 class='conformance-res-status'>Conformant: {rc.compliant}</h3>")
if rc.requirement_results:
report.append("<table class='conformance-res-tab'>")
report.append("<thead><tr><th>Requirement</th><th>Conformant</th></tr></thead>")
report.append("<tbody>")
for info_name, val in rc.requirement_results:
report.append(
"<tr>"
"<td class='conformance-res-tab-r'>"
f"{info_name}</td>"
"<td class='conformance-res-tab-v'>"
f"{val}</td>"
"</tr>"
)
report.append("</tbody>")
report.append("</table>")
report.append("</div>") # End of conformance-res
# Components without required information
if rc.components_without_info:
report.append("<div class='conformance-mis'>")
report.append(
"<p class='conformance-mis-label'>"
"Missing required information in these components:"
"</p>"
)
report.append("<ul class='conformance-mis-list'>")
for info_name, components in rc.components_without_info:
component_names = [
name if name not in (None, "") else id for name, id in components
]
report.append(
f"<li>{info_name} ({len(components)}): "
f"{', '.join(component_names)}</li>"
)
report.append("</ul>")
report.append("</div>")
# Validation messages
if rc.validation_messages:
report.append("<div class='conformance-val'>")
report.append(
"<p class='conformance-val-label'>"
"The document is not valid according to the SBOM specification"
f' ("{rc.sbom_spec}").<br />'
"The following violations were found:"
"</p>"
)
report.append(
get_validation_messages_html(rc.validation_messages, verbose=verbose)
)
report.append("</div>")
# Conformance messages
if rc.conformance_messages:
report.append("<div class='conformance-msg'>")
report.append(
"<p class='conformance-msg-label'>"
"The following conformance issues were found:"
"</p>"
)
report.append(
get_validation_messages_html(rc.conformance_messages, verbose=verbose)
)
report.append("</div>")
# structural graph issues
graph_issues_report = _generate_graph_html_report(rc)
if graph_issues_report:
report.extend(graph_issues_report)
return "\n".join(report)