Automated Compliance Report Generation

A compliance report is the artifact an external reviewer actually holds — the dated, signed document that says these datasets were checked against these standards on this date with these results. Producing it by hand from a pile of validation logs is slow, error-prone, and, worst of all, not reproducible: a report typed up after the fact cannot prove which dataset version was checked. This guide, part of the Spatial Data Audit Reporting & Compliance Governance domain, covers building a Python generator that reads stored evidence and renders both HTML summaries for internal review and archival PDF reports for formal submission — deterministically, on a schedule, with every output anchored to the exact data it describes.

Prerequisites

  1. Python 3.10+ in an isolated virtual environment (python -m venv .venv)
  2. jinja2>=3.1 — HTML report templating with autoescaping and a sandboxed environment
  3. reportlab>=4.0 — programmatic PDF generation via the platypus document framework
  4. weasyprint>=61 — optional HTML-to-PDF path when the PDF must mirror the HTML template
  5. pandas>=2.0 — evidence aggregation and per-dataset roll-up
  6. Read access to the evidence store written by your validation and license-check stages (JSON Lines is assumed below)
  7. Environment variable REPORT_OUTPUT_DIR pointing to a dated, retained output location
  8. Familiarity with the evidence-record shape defined in the parent domain (dataset id, content hash, check id, standard, result, timestamp)

Install the stack:

pip install "jinja2>=3.1" "reportlab>=4.0" "weasyprint>=61" "pandas>=2.0"

Compliance Report Generation Data Flow Evidence store feeds an aggregation step that builds a report model; the model branches to a jinja2 HTML renderer and a reportlab PDF renderer; both outputs are written to a dated retention path and their hashes recorded in the audit trail. Evidence Store JSON Lines validation · license Aggregate roll-up verdict report model jinja2 HTML audit summary reportlab PDF archival report Dated Retention + hash to trail retain-until

Concept & Spec Reference

A compliance report is a deterministic projection of stored evidence, not a fresh evaluation. The generator reads evidence records, groups them by dataset, reduces per-check results to a verdict, and renders. Every design decision follows from one constraint: the report must reproduce the compliance state as it stood when the evidence was captured, so it must never re-run a check at report time. Re-running would answer “does this pass now?” — but the report’s job is to answer “what did we find then?”

Report model fields

The intermediate model that both renderers consume is the contract of this system. Keep it explicit and serializable so a report can be regenerated from the same evidence at any future date.

Field Type Role in the report
report_id str Stable identifier, typically period + inventory hash
generated_at ISO 8601 Render timestamp, distinct from evidence capture time
reporting_period str The window the report attests to (e.g. 2025-Q2)
datasets[] list One entry per dataset, each with its verdict and checks
datasets[].dataset_id str Human-facing dataset name
datasets[].sha256 str Content hash anchoring the exact bytes checked
datasets[].verdict enum pass, fail, or partial roll-up
datasets[].checks[] list Per-check id, standard, result, detail
summary.total int Count of datasets in the report
summary.passing int Count with an all-clear verdict
summary.open_findings int Count of failing checks across the inventory
pipeline_commit str Commit that produced the evidence, for reproducibility
approver str Named sign-off for PDF submission (nullable in drafts)

Report sections

Both HTML and PDF outputs share a fixed section order so reviewers learn one layout: a header block (report id, period, generation date), an executive summary (counts and overall verdict), a per-dataset results table (dataset, hash prefix, standards, verdict), an open-findings list (failing checks with rule ids), and a sign-off block (approver, decision, date). Fixing the order also makes the two renderers easy to keep in agreement.

Implementation Walkthrough

Step 1 — Load and aggregate evidence

Read the append-only evidence log and group records by dataset. Grouping in pandas keeps the aggregation declarative and makes the roll-up verdict a single reduction. Loading from the stored log — rather than re-invoking validators — is what guarantees the report reflects a captured moment.

# aggregate.py — build a report model from stored evidence records
import json
from pathlib import Path
from collections import defaultdict


def load_evidence(log_path: Path) -> list[dict]:
    """Read a JSON Lines evidence log into a list of records."""
    records: list[dict] = []
    with log_path.open("r", encoding="utf-8") as handle:
        for line in handle:
            line = line.strip()
            if line:
                records.append(json.loads(line))
    return records


def group_by_dataset(records: list[dict]) -> dict[str, list[dict]]:
    """Group evidence records by their dataset_id, preserving order."""
    grouped: dict[str, list[dict]] = defaultdict(list)
    for rec in records:
        grouped[rec["dataset_id"]].append(rec)
    return dict(grouped)

Step 2 — Compute the roll-up verdict

Reduce each dataset’s checks to a single verdict while preserving the identity of every failing rule. A dataset is pass only when every check passed; a fail when a mandatory check failed; partial when only recommended checks failed. Preserving failing rule ids is essential — a verdict without the underlying findings is not evidence, it is an opinion.

# verdict.py — reduce per-check evidence to a dataset verdict
from datetime import datetime, timezone

MANDATORY_STANDARDS = {"ISO 19115-3", "DCAT-AP", "license"}


def dataset_verdict(checks: list[dict]) -> str:
    """Return 'pass', 'fail', or 'partial' for a dataset's checks."""
    failed = [c for c in checks if not c["passed"]]
    if not failed:
        return "pass"
    if any(c["standard"] in MANDATORY_STANDARDS for c in failed):
        return "fail"
    return "partial"


def build_report_model(grouped: dict[str, list[dict]], period: str,
                       commit: str, approver: str | None = None) -> dict:
    """Assemble the deterministic report model consumed by both renderers."""
    datasets = []
    open_findings = 0
    for dataset_id in sorted(grouped):
        checks = sorted(grouped[dataset_id], key=lambda c: c["check_id"])
        verdict = dataset_verdict(checks)
        failing = [c for c in checks if not c["passed"]]
        open_findings += len(failing)
        datasets.append({
            "dataset_id": dataset_id,
            "sha256": checks[0]["dataset_sha256"],
            "verdict": verdict,
            "checks": checks,
            "failing": failing,
        })
    passing = sum(1 for d in datasets if d["verdict"] == "pass")
    return {
        "report_id": f"{period}-{len(datasets)}",
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "reporting_period": period,
        "datasets": datasets,
        "summary": {
            "total": len(datasets),
            "passing": passing,
            "open_findings": open_findings,
        },
        "pipeline_commit": commit,
        "approver": approver,
    }

Step 3 — Render the HTML audit summary

Template the model with jinja2. Use a SandboxedEnvironment with autoescape enabled so that dataset names or check details containing HTML characters cannot break — or inject into — the rendered page. The HTML output is the fast, reviewable format that data stewards read daily; the building HTML audit summaries from metadata records guide covers the record-level variant in depth.

# render_html.py — render the report model to a reviewable HTML document
from jinja2 import Environment, DictLoader, select_autoescape
from jinja2.sandbox import SandboxedEnvironment

REPORT_TEMPLATE = """<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<title>Compliance Report {{ model.report_id }}</title></head>
<body>
<h1>Spatial Compliance Report</h1>
<p>Period: {{ model.reporting_period }} &middot;
   Generated: {{ model.generated_at }}</p>
<p>Datasets: {{ model.summary.total }} &middot;
   Passing: {{ model.summary.passing }} &middot;
   Open findings: {{ model.summary.open_findings }}</p>
<table>
<thead><tr><th>Dataset</th><th>Hash</th><th>Verdict</th></tr></thead>
<tbody>
{% for d in model.datasets %}
<tr><td>{{ d.dataset_id }}</td>
    <td>{{ d.sha256[:12] }}</td>
    <td>{{ d.verdict }}</td></tr>
{% endfor %}
</tbody></table>
</body></html>
"""


def render_html(model: dict) -> str:
    """Render the report model to an autoescaped HTML string."""
    env = SandboxedEnvironment(
        loader=DictLoader({"report.html": REPORT_TEMPLATE}),
        autoescape=select_autoescape(["html"]),
    )
    template = env.get_template("report.html")
    return template.render(model=model)

Step 4 — Render the PDF report

For formal submission, render an archival PDF. The reportlab platypus framework gives precise control over tables and a signature block with no system dependencies. The dedicated generating PDF compliance reports with Python and ReportLab guide expands this into a full runnable script; the excerpt below shows the core structure.

# render_pdf.py — render the report model to an archival PDF
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer


def render_pdf(model: dict, out_path: str) -> None:
    """Write an archival compliance PDF from the report model."""
    styles = getSampleStyleSheet()
    doc = SimpleDocTemplate(out_path, pagesize=A4, title=model["report_id"])
    flow = [
        Paragraph("Spatial Compliance Report", styles["Title"]),
        Paragraph(f"Period: {model['reporting_period']}", styles["Normal"]),
        Spacer(1, 12),
    ]
    rows = [["Dataset", "Hash", "Verdict"]]
    for d in model["datasets"]:
        rows.append([d["dataset_id"], d["sha256"][:12], d["verdict"]])
    table = Table(rows, hAlign="LEFT")
    table.setStyle(TableStyle([
        ("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
        ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
        ("FONTSIZE", (0, 0), (-1, -1), 9),
    ]))
    flow.append(table)
    flow.append(Spacer(1, 24))
    approver = model.get("approver") or "________________"
    flow.append(Paragraph(f"Approved by: {approver}", styles["Normal"]))
    doc.build(flow)

If your team already maintains the HTML template as the canonical layout, render the same HTML to PDF with weasyprint instead, so both formats stay pixel-aligned:

# render_pdf_weasy.py — HTML-to-PDF path when the PDF must mirror the HTML
from weasyprint import HTML


def html_to_pdf(html_string: str, out_path: str) -> None:
    """Render an HTML report string to a PDF file via weasyprint."""
    HTML(string=html_string).write_pdf(out_path)

Step 5 — Schedule and retain

Run the generator on a fixed cadence — nightly for internal HTML summaries, per reporting period for signed PDFs. Write outputs under a dated path and record the report’s own hash in the audit trail so the report becomes evidence of its own existence.

# schedule_entry.py — the scheduled generation entry point
import hashlib
import os
from pathlib import Path
from datetime import date

from aggregate import load_evidence, group_by_dataset
from verdict import build_report_model
from render_html import render_html
from render_pdf import render_pdf


def generate_period_report(evidence_log: Path, period: str, commit: str) -> Path:
    """Generate dated HTML and PDF reports and return the output directory."""
    grouped = group_by_dataset(load_evidence(evidence_log))
    model = build_report_model(grouped, period, commit)

    out_dir = Path(os.environ["REPORT_OUTPUT_DIR"]) / date.today().isoformat()
    out_dir.mkdir(parents=True, exist_ok=True)

    html = render_html(model)
    html_path = out_dir / f"report-{period}.html"
    html_path.write_text(html, encoding="utf-8")

    pdf_path = out_dir / f"report-{period}.pdf"
    render_pdf(model, str(pdf_path))

    # Record the report's own hash so it is self-evidencing in the trail
    digest = hashlib.sha256(html_path.read_bytes()).hexdigest()
    (out_dir / f"report-{period}.sha256").write_text(digest, encoding="utf-8")
    return out_dir

Validation & CI Integration

Reports are code output, so they need the same regression protection as any other artifact. Verify that a known evidence fixture always produces the expected verdicts and that the renderers do not crash on edge cases such as an empty inventory.

# tests/test_report_generation.py
import pytest
from verdict import dataset_verdict, build_report_model
from render_html import render_html

PASS_CHECKS = [
    {"check_id": "iso.mandatory", "standard": "ISO 19115-3",
     "passed": True, "dataset_sha256": "a" * 64, "detail": ""},
]
FAIL_CHECKS = [
    {"check_id": "license.present", "standard": "license",
     "passed": False, "dataset_sha256": "b" * 64, "detail": "no dct:license"},
]


def test_all_pass_gives_pass_verdict():
    assert dataset_verdict(PASS_CHECKS) == "pass"


def test_mandatory_failure_gives_fail_verdict():
    assert dataset_verdict(FAIL_CHECKS) == "fail"


def test_html_renders_and_escapes():
    model = build_report_model({"layer<x>": PASS_CHECKS}, "2025-Q2", "abc123")
    html = render_html(model)
    assert "layer&lt;x&gt;" in html  # autoescape neutralised the angle brackets


def test_empty_inventory_is_safe():
    model = build_report_model({}, "2025-Q2", "abc123")
    assert model["summary"]["total"] == 0

Wire report generation into CI so a broken generator is caught before it reaches a scheduled run. The following GitHub Actions job renders a report from a fixture evidence log on every change to the reporting code:

name: Compliance Report Generation

on:
  push:
    paths:
      - 'reporting/**'
      - '.github/workflows/report-generation.yml'
  pull_request:
    paths:
      - 'reporting/**'

jobs:
  render-report:
    runs-on: ubuntu-latest
    env:
      REPORT_OUTPUT_DIR: ./_reports
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'
      - name: Install dependencies
        run: pip install "jinja2>=3.1" "reportlab>=4.0" "pandas>=2.0" pytest
      - name: Run report tests
        run: pytest tests/test_report_generation.py -q
      - name: Render sample report from fixture
        run: python reporting/schedule_entry.py --evidence tests/fixtures/evidence.jsonl --period ci-sample
      - name: Upload report artifact
        uses: actions/upload-artifact@v4
        with:
          name: compliance-report
          path: _reports/

Pinning the report as a build artifact means every merge produces a downloadable proof that the generator still works, and reviewers can open the rendered output directly from the run. Where report generation feeds a policy enforcement gate for data PRs, a fail verdict in the generated report can itself block the merge.

Derivative & Lineage Management

A compliance report is a claim about a specific dataset version, so it must move in lockstep with the data it describes. When a dataset is reprocessed, its content hash changes, and any report referencing the old hash now describes bytes that no longer exist in production — which is correct and intentional: the old report remains valid evidence for the version it named, and a new report must be generated for the new version.

Key obligations when data changes underneath a report:

  • Never mutate a published report. If a finding is remediated, generate a new report for the corrected dataset version. The old report stays in retention as the record of the prior state. Editing it in place destroys the very evidence the report exists to provide.
  • Carry the source hash into derived-product reports. A report for a derived layer should reference the content hashes of its sources so the report chain mirrors the lineage chain tracked in spatial data lineage and provenance tracking.
  • Re-run on license changes. A license status pulled from geospatial data licensing compliance checks can change without the geometry changing. Treat a license-status change as a trigger for report regeneration even when the dataset bytes are identical.
  • Version the template. Record which report template version produced each output. A layout change should be a deliberate, dated event, not a silent difference between two reports that a reviewer might read as a data change.

Pitfalls & Resolution Table

Pitfall Root Cause Resolution Strategy
Report cannot prove which data was checked Report references a dataset by name only, not by content hash Anchor every report row to the dataset SHA-256 recorded on its evidence records
Two runs of the same evidence produce different reports Non-deterministic iteration over dicts or unsorted findings Sort datasets and checks by stable keys before rendering; freeze the generation timestamp only at write time
HTML injection from dataset names Templating without autoescaping, or string concatenation instead of a template engine Use jinja2 SandboxedEnvironment with select_autoescape(["html"]); never build HTML by string joins
PDF tables overflow the page and truncate Fixed column widths larger than the page frame in reportlab Let Table size columns automatically or set widths summing under the frame; enable repeatRows=1 for headers across pages
Report claims pass while data is non-compliant Generator re-runs checks at report time and reads current (fixed) data Read only stored evidence records; never invoke validators inside the report generator
Missing findings on a failed dataset Verdict reduction discards the list of failing rules and keeps only the boolean Preserve every failing check id in the model; render an open-findings section, not just a verdict column
weasyprint PDF differs from reportlab PDF Two independent layout code paths drift over time Pick one canonical renderer per output; if both are needed, derive the PDF from the same HTML via weasyprint
Reports overwrite each other Output path lacks a date or period component Write under a dated directory and include the reporting period in the filename; treat outputs as append-only