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
- Python 3.10+ in an isolated virtual environment (
python -m venv .venv) jinja2>=3.1— HTML report templating with autoescaping and a sandboxed environmentreportlab>=4.0— programmatic PDF generation via the platypus document frameworkweasyprint>=61— optional HTML-to-PDF path when the PDF must mirror the HTML templatepandas>=2.0— evidence aggregation and per-dataset roll-up- Read access to the evidence store written by your validation and license-check stages (JSON Lines is assumed below)
- Environment variable
REPORT_OUTPUT_DIRpointing to a dated, retained output location - 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"
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 }} ·
Generated: {{ model.generated_at }}</p>
<p>Datasets: {{ model.summary.total }} ·
Passing: {{ model.summary.passing }} ·
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<x>" 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 |
Related
- Spatial Data Audit Reporting & Compliance Governance — the parent domain that defines the evidence model these reports consume
- Generating PDF Compliance Reports with Python and ReportLab — a full runnable platypus script for archival PDF reports
- Building HTML Audit Summaries from Metadata Records — rendering record-level HTML summaries that flag missing mandatory fields
- Audit Trail & Evidence Retention — the append-only store the report hash is recorded into
- Metadata Schema Validation & Linting — an upstream source of the pass/fail evidence rolled up here