Source code for ntia_conformance_checker.report

# 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 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)