Skip to main content

pytest-live-report

A pytest plugin that writes its HTML report while your tests run, one card per test case, straight into a single self-contained file.

Open report.html in a browser and refresh: finished tests are already there. No Java, no template directory to configure, no "wait for the whole suite to finish" step — and if you hit Ctrl+C, everything that already ran is still in the report.

$ pip install pytest-live-report
$ pytest --live-report-path report.html

That is the whole setup. The plugin registers itself through its pytest11 entry point, so there is no conftest.py edit and no configuration file.

What you get

  • One card per test case, written the moment the case finishes, with the run's start/end time, wall-clock duration, function name, and its docstring as the description.
  • Status filtering and name search on the page, plus a donut chart of passed / failed / skipped so a long run is scannable.
  • The failure traceback is already in the card, so you do not have to scroll the terminal to find what broke.
  • A single HTML file: the stylesheet, the page script and any screenshots or files you attach are inlined. Mail it, archive it, open it offline — it works.
  • Structured data for scripts: every page carries a machine-readable run manifest, and a JSON record per test case holding its status, timings, error and the logs you wrote. The bundled read_report() hands them back to your script — see Reading the report from a script.
  • pytest-xdist support: with -n, workers hand their cards to the controller, which is the only process that writes the file.

Writing content from a test

from pytest_live_report import live_report


def test_login():
    """Verify the login flow."""
    live_report.log("POST /login as admin")
    live_report.log("status", 200, live_report.span_html("OK", bold=True, code=True))

    live_report.case_name("Login flow")           # override the card title
    live_report.case_desc("Covers the redirect")  # override the docstring
    live_report.save_image("screenshots/home.png", caption="after login")
    live_report.attach("logs/server.log", caption="server log")  # any file, as a download link

    assert True
Method What it does
live_report.log(*parts) Appends a log line to the current card. *parts are joined with spaces like print; newlines become separate lines; everything is HTML-escaped.
live_report.span_html(text, *, bold=False, code=False) Renders an inline fragment (bold / monospace). Pass the result to live_report.log() to have it embedded as-is. The only entry point you may call outside a test case.
live_report.case_name(text) Overrides the card title. A parametrized suffix is kept: test_login[admin] shows as Login flow[admin].
live_report.case_desc(text) Overrides the card description (default: the test function's docstring).
live_report.save_image(path, caption=None) Inlines an image as a base64 data URI. Raises FileNotFoundError if the path is not an existing file.
live_report.attach(path, caption=None) Inlines any file as a base64 data URI and puts a download link on the card, next to the file name, size and MIME type. The report stays a single file, so whoever you send it to can download the attachment. Raises FileNotFoundError if the path is not an existing file.

Calling any of these outside a test case only emits a warning; it never fails your run. The report system never raises into your tests — a broken report becomes a warning and the report is disabled for that session.

Command line options

Option Meaning
--live-report-path PATH Where to write the report. Relative paths resolve against rootdir. Without this option the plugin does nothing at all.
--live-report-title TITLE Report title (default: pytest report).

How statuses are counted

pytest has more outcomes than the three the report shows, so they are folded in:

Report status Comes from
passed passed
failed failed and error — a broken fixture (setup error) or a failing teardown counts as a failed case
skipped skipped and xfail

A case is written only once all three of its phases are done, so a case that is still running is simply not in the report yet. A case interrupted by Ctrl+C is dropped as well — you never get a half-written card.

pytest-xdist

$ pytest -n 4 --live-report-path report.html

Workers never touch the report file: each worker attaches its finished card to the test report, xdist ships it back, and the controller writes it. The result has the same cards, counts and content as a single-process run.

One difference: cards appear in the order workers finish, not in test order. Sort or group by the nodeid in each card's JSON record if you need a stable order.

Reading the report from a script

The plugin ships a parser — hand it the file path and you get the data back:

from pytest_live_report import read_report

report = read_report("report.html")

print(report["counts"])                   # {'total': 42, 'passed': 40, 'failed': 1, 'skipped': 1}
print(report["manifest"]["exitstatus"])

for case in report["cases"]:
    print(case["nodeid"], case["status"], case["duration"])

The returned dict has three keys. manifest is the run manifest, and it is None only when the process was killed before pytest could shut down — that is how a truncated report is told apart from one that ran to the end. Ctrl+C is not that case: pytest still finishes the session, so the manifest is written and its exitstatus is 2. counts always holds total / passed / failed / skipped, tallied from the cases on the page, so it is available even for a truncated report. cases holds one dict per test case, in the order they appear in the file. read_report() raises FileNotFoundError if the path does not exist, and ValueError when the file is not a pytest-live-report report or a JSON block is broken.

The same data is embedded as plain JSON blocks, so if you would rather not depend on the package, a regex is enough:

import json
import re
from pathlib import Path

html = Path("report.html").read_text(encoding="utf-8")

# The run manifest: its presence means the run finished.
manifest = json.loads(
    re.search(r'<script type="application/json" id="rpt-run">(.*?)</script>', html).group(1)
)

# One record per test case.
records = [
    json.loads(block)
    for block in re.findall(
        r'<script type="application/json" class="rpt-case-json">(.*?)</script>', html, re.S
    )
]

Each record holds the case's status, duration, started / finished, error, traceback and skip_reason, plus a summary of what you wrote from inside the test:

# live_report.log("POST /login", 200) becomes one text entry; newlines split into more
for entry in records[0]["logs"]:
    print(entry["ts"], entry["text"])   # 11:14:55 POST /login 200

for shot in records[0]["shots"]:
    print(shot["caption"], shot["bytes"], shot["mime"])   # after pay 29696 image/png

for att in records[0]["attachments"]:
    print(att["name"], att["bytes"], att["mime"])         # server.log 512 text/plain

logs carries the plain text you passed to live_report.log(), so it needs no HTML unescaping. Lines written with live_report.span_html() additionally carry an html key with the rendered fragment; text stays clean either way. Newlines split into separate entries, so the entry count matches the Log (n) line on the card.

shots carries the caption, byte size and MIME type of each screenshot — enough to check that a screenshot was taken, without inflating the report. The image data itself lives once, in the card's <img src="data:...">.

attachments carries the name, caption, byte size and MIME type of every file you attached. The bytes themselves live once, in the card's download link (<a download="..." href="data:...">), so the report can be forwarded on its own. The MIME type is taken from the image signature first, then guessed from the file extension, and falls back to application/octet-stream.

Per-case records are versioned by their v field, currently 1. Only additive changes happen within a version, so read optional fields defensively:

for entry in records[0].get("logs", []):
    ...

v is bumped only if a field is renamed, removed, or changes meaning.

The manifest is written whenever the session reaches its end, and that includes Ctrl+C: the run is interrupted, but pytest still finishes the session, so the report gets its manifest with exitstatus 2. The file has no manifest only when the process was killed outright, for example by the stop button in an IDE — that is how the page (and your script) can tell a truncated report from a finished one.

Requirements

  • Python 3.10+
  • pytest 7.4+
  • pytest-xdist is optional: pip install pytest-live-report[xdist]

MIT licensed. Copyright (c) 2026 Vsoapmac.

Found a bug or have a feature request? Please open an issue.

Metadata

Release files for pytest-live-report 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distribution (wheel)

Table of built distributions (wheels) for pytest-live-report 0.3.0
File Interpreter ABI Platform
pytest_live_report-0.3.0-py3-none-any.whl Python 3 none any Details

Release files / pytest_live_report-0.3.0-py3-none-any.whl

Download URL pytest_live_report-0.3.0-py3-none-any.whl
Size 43.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9a8377ae475fcc1835feb66eeba10ddb3c77f7b4a78bd272ee09be0907c85be0
BLAKE2b-256 checksum
How to use checksums
31844378e6e1824fed0dd3f35617238b5730777b2d8fb6d3ba4b178bfb15f6d6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.12

Release history Release notifications | RSS feed

This release

0.3.0 This release

1 release file

0.2.0

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page