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-xdistis optional:pip install pytest-live-report[xdist]
License and links
MIT licensed. Copyright (c) 2026 Vsoapmac.
- Source, issues and changelog: https://github.com/Vsoapmac/pytest-live-report
- Package on PyPI: https://pypi.org/project/pytest-live-report/
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)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|