Files
pdf-reporting/report_generator/rendering.py
T
alexanderandClaude Opus 5.5 2d88ffa953 Report lines of code and the main languages from cloc.txt
The project page takes the size from cloc's sum and names the three to
five largest languages, leaving out prose and data files such as Markdown,
text, CSV and JSON, and anything under 1% of the code beyond the third.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:14:14 +02:00

119 lines
4.2 KiB
Python

"""Turns collected sources into a PDF.
Three steps, each somewhere else: this module decides which stored file feeds which part of the
report, :mod:`context` maps the parsed artifacts onto the page,
and :mod:`layout` draws it.
Adding a section to the report is an entry in :data:`INPUTS` and a block in the template -- not
another renderer.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from .vendor.cc_common.api import PipelineError
from .vendor.cc_common.api import context as logging_context
from .cloc import parse_cloc
from .context import build_context
from .layout import render_pdf
if TYPE_CHECKING:
from .sources import ReportSources
# Which stored file each input of the report is taken from, under the names the pipeline stages
# write. Several per input because the same document travels under more than one name depending on
# which stage produced it; the first one a scan carries wins.
INPUTS = {
"metadata": ("metadata.json", "meta.json"),
"findings": (
"merged.sarif",
"sast_scanner_ranked.sarif",
"polygraph.sarif",
"sast_scanner_combined.sarif",
"prism.sarif",
),
"vulnerabilities": ("vdr.json", "vdr-2.json", "vdr-3.json"),
}
# A report needs at least one of these to have anything to say. The rest is reported as far as it
# was found: a scan that ran only half the pipeline is a shorter report, not a refusal.
REQUIRED_ANY = ("findings", "vulnerabilities")
# What the file is called. The scan it is about is in the name, because a directory of these is
# otherwise unreadable.
NAME = "report_{scan}.pdf"
def render(
sources: ReportSources,
metrics: dict[str, Any] | None = None,
*,
project: str | None = None,
manufacturer: str | None = None,
) -> tuple[str, bytes]:
"""Render the report of one scan.
Args:
sources: What :func:`sources.collect` found.
metrics: The metrics document, if one was built.
project: The project name the caller supplied; overrides the metadata's.
manufacturer: The manufacturer the caller supplied.
Returns:
The file name the report should be saved under, and its bytes.
Raises:
PipelineError: The scan carries neither findings nor vulnerabilities, or
one of its documents cannot be parsed. Both are reported by name: "no
PDF was produced" leaves whoever ran it guessing which stage of their
pipeline did not finish.
"""
documents = {name: _parsed(sources, name, candidates) for name, candidates in INPUTS.items()}
documents["metrics"] = metrics
documents["cloc"] = parse_cloc(sources.first("cloc.txt"))
if all(documents.get(name) is None for name in REQUIRED_ANY):
message = f"Scan {sources.scan_id} carries no {' and no '.join(REQUIRED_ANY)}, so no report can be built."
raise PipelineError(message)
found = [name for name, document in documents.items() if document is not None]
logging_context.logger.info(f"Rendering the report from {', '.join(found)}...")
context = build_context(sources, documents, project=project, manufacturer=manufacturer)
return NAME.format(scan=sources.scan_id), render_pdf(context)
def _parsed(sources: ReportSources, name: str, candidates: tuple[str, ...]) -> Any | None: # noqa: ANN401
"""Read one of the scan's documents, or nothing where it stored none.
Args:
sources: The scan and its files.
name: What the document is called in :data:`INPUTS`.
candidates: The file names it may have been stored under.
Returns:
The parsed JSON, or ``None`` when the scan carries none.
Raises:
PipelineError: The file is there and is not readable. Kept apart from an
absent one on purpose: one is a pipeline that did not run a stage,
the other is a stage that produced something broken, and a report
that treated them alike would hide the second.
"""
content = sources.first(*candidates)
if content is None:
return None
try:
return json.loads(content)
except ValueError as error:
message = f"The {name} of scan {sources.scan_id} is not readable JSON: {error}"
raise PipelineError(message) from error