Bobobk

Practical Python, Linux and automation guides

Convert CSV to an HTML Report with Python: Escaping, Multiline Fields, and Overwrite Protection

2026-10-24 Technology
Convert CSV to an HTML Report with Python: Escaping, Multiline Fields, and Overwrite Protection
On this page

Your automation already produces CSV, but inspecting a small result still means opening spreadsheet software. A standalone HTML table lets a browser display the same fields, including commas and line breaks, without extra packages.

This beginner-to-intermediate tutorial parses CSV, checks headers and field counts, escapes HTML text, and exports a report. The report needs no JavaScript, external stylesheets, or network resources.

1. Method and input rules

  1. Input is comma-separated UTF-8 CSV, optionally with a leading BOM. The first record is the header; names cannot be blank or duplicated. Comparisons do not trim spaces.
  2. Every data record must have the same field count as the header. Blank physical lines also fail that check. A header-only file is valid and reports 0 records.
  3. All fields remain strings, so 001 stays 001. Escape fields, headers, and the report title as HTML text; tags in CSV are displayed as text.
  4. The default maximum is 1000 data records. Exceeding it fails instead of producing a truncated report. This is not a byte or memory limit: the generated HTML stays in memory, so use this tool for small reports.

For required business column names or value checks, add CSV structure validation first. This renderer checks general table structure only.

2. Prepare the environment and demo data

Use an existing Python 3.10 or newer interpreter. No third-party dependencies are needed. These examples were verified on Linux with Python 3.11.4. Choose a new experiment directory:

python3 --version
mkdir csv-html-demo
cd csv-html-demo
cat > results.csv <<'CSV'
sample_id,note
001,中文结果
002,"alpha, beta"
003,"first line
second line"
004,<script>alert(1)</script>
CSV

Save the next section as csv_to_html.py, or download the complete script. The fourth record deliberately contains tag text to test escaping.

3. Complete implementation

"""Build a small standalone HTML table from UTF-8 CSV. Python 3.10+."""
import argparse
import csv
from html import escape
from pathlib import Path
import sys


def positive(value):
    number = int(value)
    if number < 1:
        raise argparse.ArgumentTypeError("must be at least 1")
    return number


def render(source, title, max_rows):
    with source.open(encoding="utf-8-sig", newline="") as file:
        reader = csv.reader(file, strict=True)
        header = next(reader, None)
        if not header or any(not name.strip() for name in header):
            raise ValueError("missing or empty column name")
        if len(set(header)) != len(header):
            raise ValueError("duplicate column name")
        parts = []
        for number, row in enumerate(reader, 1):
            if number > max_rows:
                raise ValueError(f"more than {max_rows} data records")
            if len(row) != len(header):
                raise ValueError(f"record {number}: expected {len(header)} fields, got {len(row)}")
            parts.append("<tr>" + "".join("<td>" + escape(cell) + "</td>" for cell in row) + "</tr>")
    headings = "".join('<th scope="col">' + escape(name) + "</th>" for name in header)
    safe_title = escape(title)
    document = f'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{safe_title}</title>
<style>
body {{ font: 16px system-ui, sans-serif; margin: 2rem; color: #172033; }}
.scroll {{ overflow-x: auto; }}
table {{ border-collapse: collapse; }}
th, td {{ border: 1px solid #b8c2d1; padding: .5rem; text-align: left;
           white-space: pre-wrap; overflow-wrap: anywhere; max-width: 36rem; }}
th {{ background: #edf2f8; }}
</style>
</head>
<body>
<h1>{safe_title}</h1>
<p>Records: {len(parts)}</p>
<div class="scroll"><table>
<thead><tr>{headings}</tr></thead>
<tbody>{"".join(parts)}</tbody>
</table></div>
</body>
</html>
'''
    return document, len(parts)


def main():
    parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
    parser.add_argument("source", type=Path)
    parser.add_argument("output", type=Path)
    parser.add_argument("--title", default="CSV report")
    parser.add_argument("--max-rows", type=positive, default=1000)
    args = parser.parse_args()
    try:
        document, count = render(args.source, args.title, args.max_rows)
        payload = document.encode("utf-8")
        with args.output.open("xb") as file:
            file.write(payload)
    except (OSError, UnicodeError, csv.Error, ValueError) as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    print(f"OK: {count} records -> {args.output}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

The official Python CSV documentation specifies opening files with newline=""; the parser handles commas and line breaks inside quoted fields. strict=True reports some parsing errors such as malformed quotes. Header and field-count checks remain separate.

The html.escape() documentation lists the HTML special characters it converts. Dynamic values here appear only as text in titles, headings, and cells. Do not reuse this pattern directly for dynamic scripts, CSS, or URLs. The table uses white-space: pre-wrap to display embedded line breaks.

The output opens only after the entire input passes. The x mode of open() rejects an existing path, including the input file itself. This is not an atomic commit or crash-recovery mechanism: a disk-full error while writing can leave an incomplete new file. After resolving the failure, rerun with a new output name.

4. Run and view the report

Run these commands in the experiment directory:

python3 csv_to_html.py --help
python3 csv_to_html.py results.csv report.html --title 'Batch <A> & results'
python3 -m http.server 8000 --bind 127.0.0.1

The second command prints OK: 4 records -> report.html. Open http://127.0.0.1:8000/report.html in a browser on the same machine, or open the HTML file directly. Press Ctrl+C in the server terminal when finished; the server listens only on the local machine.

You should see four records, readable Chinese text, the unchanged 001, a comma inside one cell, and a note displayed on two lines. The final cell displays the literal text <script>alert(1)</script> without a popup. The report contains the source data; check that its contents are appropriate before sharing it.

5. Verify output and error behavior

Open another terminal in the same experiment directory and check the actual file:

python3 - <<'PY_CHECK'
from pathlib import Path
text = Path("report.html").read_text(encoding="utf-8")
assert "Records: 4" in text
assert "<td>001</td>" in text
assert "中文结果" in text
assert "alpha, beta" in text
assert "first line\nsecond line" in text
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in text
assert "<script>" not in text
assert "Batch &lt;A&gt; &amp; results" in text
print("OK: records, text, and escaping")
PY_CHECK

Expect OK: records, text, and escaping. Then test overwrite protection, the record limit, and a bad field count. Run these in a regular interactive terminal to inspect each exit status:

python3 csv_to_html.py results.csv report.html
printf 'exit=%s\n' "$?"
python3 csv_to_html.py results.csv too-many.html --max-rows 3
printf 'exit=%s\n' "$?"
printf 'a,b\n1,2,3\n' > bad.csv
python3 csv_to_html.py bad.csv bad.html
printf 'exit=%s\n' "$?"

All three calls should return 1. The existing report.html remains unchanged; too-many.html and bad.html are not created. Invalid arguments such as --max-rows 0 return argparse status 2. Successful generation returns 0.

Validation also covered UTF-8 BOM, CRLF, header-only input, empty input, duplicate headers, invalid UTF-8, malformed quotes, and HTML characters in headers and titles. For large datasets, stream and aggregate the CSV into a small table before rendering it.

6. Troubleshooting

Symptom Action
File exists Choose a new output name; preserve old reports that you still need.
missing or empty column name / duplicate column name Correct the first CSV record so each header is nonblank and unique.
expected ... fields Check that record’s quoting, delimiters, and extra blank lines; do not split on commas manually.
more than ... data records Filter or aggregate first; raise --max-rows only after assessing the resulting size.
A decoding error such as UnicodeDecodeError Identify and convert the encoding using the CSV encoding tutorial; do not ignore invalid bytes.
field larger than field limit Reduce fields that are too long for a browser table, or explicitly assess and adjust the parser limit.
No such file or directory / Permission denied Check the input path and choose an existing writable output directory.

Summary

Use a CSV parser to preserve records and HTML text escaping to preserve their contents in a small standalone report. Check exit statuses, overwrite protection, and the generated content before adding this step after your regular batch summary.

Tags

1024 2fa ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation background backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn checkpoint cli cloudflare codex cofoldarena concurrency configparser configuration copy cprofile cpu monitoring cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools difflib disk-space disown dns docker dovecot download economics electron encoding esm esm2 esm3 esmc esmfold2 etag evaluation fable faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io file-management filecmp filenames files flashppi flask folium frontend game generator git github-actions google google-research gpt-6.1-sol grep gzip harness heapq hls html http hugo imports indexnow ipaddress jailbreak javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics multiprocessing mutation mysql nanobert nanobodies networking networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance permissions phaser pillow pip postfix preprocessing print probability prompt-engineering protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security seo sequence-identity sha256 shell signals sitemap sklearn socket solana somaticsignatures sorting spl sql sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml totp tracemalloc trade tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode upsert urllib usdt utf-8 uv venv vhhbert vite wallet web-tools webp wordcloud wordpress workflow xml yaml zipfile zoneinfo 迅雷解析

友情链接