Metadata-Version: 2.5
Name: pytest-live-report
Version: 0.3.0
Summary: A pytest plugin that writes its HTML report while your tests are still running
Project-URL: Homepage, https://github.com/Vsoapmac/pytest-live-report
Project-URL: Repository, https://github.com/Vsoapmac/pytest-live-report
Project-URL: Issues, https://github.com/Vsoapmac/pytest-live-report/issues
Project-URL: Changelog, https://github.com/Vsoapmac/pytest-live-report/releases
Author-email: Vsoapmac <49892459+Vsoapmac@users.noreply.github.com>
License-Expression: MIT
License-File: LICENSE
Keywords: html-report,live-report,pytest,pytest-plugin,test-automation,test-report,testing,xdist
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Pytest
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Software Development :: Testing
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: pytest>=7.4
Provides-Extra: xdist
Requires-Dist: pytest-xdist>=3.0; extra == 'xdist'
Description-Content-Type: text/markdown

# 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.

```console
$ 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](#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

```python
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

```console
$ 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:

```python
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:

```python
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:

```python
# 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:

```python
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]`

## 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](https://github.com/Vsoapmac/pytest-live-report/issues).

