"""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`. """ import json from pathlib import Path from uuid import UUID from pydantic import BaseModel, Field from ..vendor.cc_common.api import PipelineError, context from ..vendor.cc_common.utils import print # Imported for the properties model; the store half of the tool is the only part that needs it. from ..vendor.persistent_storage.shared import StorageConfiguration from ..metrics import build_metrics 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, write_metrics=True) def _report(sources: ReportSources, path: Path | None, *, write_metrics: bool = False) -> 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. """ metrics = build_metrics(sources) name, content = render(sources, metrics) destination = _destination(path, name) try: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(content) if write_metrics: (destination.parent / "metrics.json").write_text( json.dumps(metrics, indent=2), encoding="utf-8" ) 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