From d76e610070175a9817c31d6d8d62fbec965de01e Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Thu, 29 Jan 2026 17:59:27 +0000 Subject: [PATCH] Implement ReportGenerator class --- src/py_dvt_ate/reporting/generator.py | 199 ++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 src/py_dvt_ate/reporting/generator.py diff --git a/src/py_dvt_ate/reporting/generator.py b/src/py_dvt_ate/reporting/generator.py new file mode 100644 index 0000000..d50b395 --- /dev/null +++ b/src/py_dvt_ate/reporting/generator.py @@ -0,0 +1,199 @@ +"""Report generator orchestrating the full report generation pipeline. + +This module provides the main ReportGenerator class that coordinates +data gathering, chart generation, HTML rendering, and PDF conversion. +""" + +from datetime import datetime +from pathlib import Path +from typing import Protocol +from uuid import UUID + +from py_dvt_ate.data.repository import ITestRepository +from py_dvt_ate.reporting.charts import ChartGenerator +from py_dvt_ate.reporting.exceptions import ReportGenerationError +from py_dvt_ate.reporting.models import ReportConfig, ReportData +from py_dvt_ate.reporting.renderers import HTMLRenderer, PDFRenderer + + +class IReportGenerator(Protocol): + """Protocol for report generators.""" + + def generate(self, run_id: UUID, output_path: Path | None = None) -> Path: + """Generate a PDF report for a test run. + + Args: + run_id: UUID of the test run. + output_path: Optional output path. If None, uses default location. + + Returns: + Path to the generated PDF file. + """ + ... + + def generate_bytes(self, run_id: UUID) -> bytes: + """Generate a PDF report and return as bytes. + + Args: + run_id: UUID of the test run. + + Returns: + PDF document as bytes. + """ + ... + + +class ReportGenerator: + """Generates PDF reports from test run data. + + This class orchestrates the full report generation pipeline: + 1. Fetch test run data from repository + 2. Generate charts from measurements + 3. Render HTML from templates + 4. Convert HTML to PDF + + Example: + >>> from py_dvt_ate.data.repository import SQLiteRepository + >>> from py_dvt_ate.reporting import ReportGenerator, ReportConfig + >>> + >>> repo = SQLiteRepository("./data/py_dvt_ate.db") + >>> config = ReportConfig(company_name="My Company") + >>> generator = ReportGenerator(repo, config) + >>> pdf_path = generator.generate(run_id) + """ + + def __init__( + self, + repository: ITestRepository, + config: ReportConfig | None = None, + reports_dir: Path | None = None, + ) -> None: + """Initialise the report generator. + + Args: + repository: Test data repository for fetching run data. + config: Report configuration. Uses defaults if not provided. + reports_dir: Directory for generated reports. Defaults to ./data/reports. + """ + self.repository = repository + self.config = config or ReportConfig() + self.reports_dir = reports_dir or Path("./data/reports") + + self._html_renderer = HTMLRenderer() + self._pdf_renderer = PDFRenderer() + self._chart_generator = ChartGenerator(dpi=self.config.chart_dpi) + + def _gather_data(self, run_id: UUID) -> ReportData: + """Gather all data needed for the report. + + Args: + run_id: UUID of the test run. + + Returns: + ReportData containing run, results, measurements, and charts. + + Raises: + ReportGenerationError: If data gathering fails. + """ + try: + run = self.repository.get_run(run_id) + results = self.repository.get_results(run_id) + measurements = self.repository.get_measurements_dataframe(run_id) + + # Generate charts if enabled + charts: dict[str, str] = {} + if self.config.include_charts: + charts = self._chart_generator.generate_all(run, results, measurements) + + return ReportData( + run=run, + results=results, + measurements=measurements, + charts=charts, + config=self.config, + ) + + except ValueError as e: + msg = f"Failed to gather data for run {run_id}: {e}" + raise ReportGenerationError(msg) from e + + def _generate_filename(self, run_id: UUID, test_name: str) -> str: + """Generate a filename for the report. + + Args: + run_id: UUID of the test run. + test_name: Name of the test. + + Returns: + Filename string with timestamp. + """ + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_name = test_name.replace(" ", "_").replace("/", "_") + return f"{safe_name}_{str(run_id)[:8]}_{timestamp}.pdf" + + def generate(self, run_id: UUID, output_path: Path | None = None) -> Path: + """Generate a PDF report for a test run. + + Args: + run_id: UUID of the test run. + output_path: Optional output path. If None, uses default location. + + Returns: + Path to the generated PDF file. + + Raises: + ReportGenerationError: If report generation fails. + """ + try: + # Gather data + data = self._gather_data(run_id) + + # Determine output path + if output_path is None: + self.reports_dir.mkdir(parents=True, exist_ok=True) + filename = self._generate_filename(run_id, data.run.test_name) + output_path = self.reports_dir / filename + + # Render HTML + html = self._html_renderer.render(data) + + # Convert to PDF + self._pdf_renderer.render_to_file(html, output_path) + + return output_path + + except ReportGenerationError: + raise + except Exception as e: + msg = f"Failed to generate report for run {run_id}: {e}" + raise ReportGenerationError(msg) from e + + def generate_bytes(self, run_id: UUID) -> bytes: + """Generate a PDF report and return as bytes. + + Useful for streaming downloads without writing to disk. + + Args: + run_id: UUID of the test run. + + Returns: + PDF document as bytes. + + Raises: + ReportGenerationError: If report generation fails. + """ + try: + # Gather data + data = self._gather_data(run_id) + + # Render HTML + html = self._html_renderer.render(data) + + # Convert to PDF bytes + return self._pdf_renderer.render_to_bytes(html) + + except ReportGenerationError: + raise + except Exception as e: + msg = f"Failed to generate report bytes for run {run_id}: {e}" + raise ReportGenerationError(msg) from e