Files
pdf-reporting/report_generator/commands/generate.py
T

149 lines
5.2 KiB
Python

"""The two ways to ask for a report: out of the store, or out of a folder.
Two commands rather than one with a mode switch, because the two need different things and a
command that demands a database host in order to read a directory is a command nobody will use for
that. What they share is everything after the artifacts are in hand, which is :func:`_report`.
"""
from pathlib import Path
from uuid import UUID
from pydantic import BaseModel, Field
from cc_common.api import PipelineError, context
from cc_common.utils import print
# Imported for the properties model; the store half of the tool is the only part that needs it.
from persistent_storage.shared import StorageConfiguration
from ..rendering import render
from ..sources import ReportSources, collect, collect_from_directory
# Where the report goes when the caller names no path.
DEFAULT_DIRECTORY = Path("reports")
class GenerateProperties(BaseModel):
"""Configuration properties for the generate CLI command.
Attributes:
scan_id: The scan to report on. The store is keyed by it.
path: Where to write the PDF. A directory is written into under the name
the renderer chose; leaving it out writes into ``reports/``.
storage_config: The store the scan's artifacts are read from.
"""
scan_id: UUID = Field(description="The scan the report should be generated for.")
path: Path | None = Field(default=None, description="Where to write the PDF report.")
storage_config: StorageConfiguration
class FolderProperties(BaseModel):
"""Configuration properties for the from-folder CLI command.
Attributes:
artifacts: The folder holding the artifacts, as the pipeline exported them.
scan_id: The scan to report under. Read out of the artifacts when not
given -- an export names its own run.
path: Where to write the PDF, as above.
"""
artifacts: Path = Field(description="Folder of exported artifacts to build the report from.")
scan_id: UUID | None = Field(
default=None, description="The scan to report under. Read from the artifacts when omitted."
)
path: Path | None = Field(default=None, description="Where to write the PDF report.")
class GeneratedReport(BaseModel):
"""The report that was written.
Attributes:
scan_id: The scan it is about.
path: The file written.
size: How large it came out, in bytes.
"""
scan_id: UUID
path: Path
size: int
def print_as_cc_common_result(self) -> None:
"""Say what was written and where."""
print(f"[bold]Report for scan {self.scan_id}[/bold]")
print(f" {self.size / 1024:.0f} kB written to {self.path}")
def generate(properties: GenerateProperties) -> GeneratedReport:
"""Build the report of a scan held in the store.
Args:
properties: The scan, the store and the destination.
Returns:
The report that was written.
Raises:
PipelineError: The scan cannot be read, is missing an input the report
needs, or the destination cannot be written.
"""
return _report(collect(properties.storage_config, properties.scan_id), properties.path)
def from_folder(properties: FolderProperties) -> GeneratedReport:
"""Build the report of a scan that was exported to a folder.
Neither Postgres nor the bucket is touched. The same report, from a directory somebody was sent.
Args:
properties: The folder, optionally the scan to report under, and the destination.
Returns:
The report that was written.
Raises:
PipelineError: The folder cannot be read, names no scan, is missing an
input the report needs, or the destination cannot be written.
"""
return _report(collect_from_directory(properties.artifacts, properties.scan_id), properties.path)
def _report(sources: ReportSources, path: Path | None) -> GeneratedReport:
"""Render the collected artifacts and write the result.
Everything after the artifacts are in hand, which is the same whichever of the two collected
them -- and the reason the two commands are two callbacks rather than two pipelines.
Args:
sources: The scan and its files.
path: Where the caller asked for the PDF, if anywhere in particular.
Returns:
The report that was written.
Raises:
PipelineError: The report cannot be built or the destination not written.
"""
name, content = render(sources)
destination = _destination(path, name)
try:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(content)
except OSError as error:
message = f"Cannot write the report to '{destination}': {error}"
raise PipelineError(message) from error
context.logger.info(f"Report written to {destination} ✔")
return GeneratedReport(scan_id=sources.scan_id, path=destination, size=len(content))
def _destination(path: Path | None, name: str) -> Path:
"""Where the report goes, treating a directory as a place to put it rather than as the file."""
if path is None:
return DEFAULT_DIRECTORY / name
return path / name if path.is_dir() or not path.suffix else path