From 00d84c5d924b3caa1728dcd91ab59737e298f184 Mon Sep 17 00:00:00 2001
From: contputer0x0 <340606769+contputer0x0@users.noreply.github.com>
Date: Sun, 11 Oct 2026 19:36:11 +0200
Subject: [PATCH 1/4] Keep HTML extras and teardown logs within each test
attempt
---
src/pytest_html/basereport.py | 152 ++++++++----
src/pytest_html/report_data.py | 15 +-
testing/test_phase_attempts.py | 440 +++++++++++++++++++++++++++++++++
3 files changed, 557 insertions(+), 50 deletions(-)
create mode 100644 testing/test_phase_attempts.py
diff --git a/src/pytest_html/basereport.py b/src/pytest_html/basereport.py
index cfcc74b2..9c66fa31 100644
--- a/src/pytest_html/basereport.py
+++ b/src/pytest_html/basereport.py
@@ -31,7 +31,9 @@ def __init__(self, report_path, config, report_data, template, css):
config.getini("max_asset_filename_length")
)
- self._reports = defaultdict(dict)
+ self._reports = {}
+ self._implicit_attempts = defaultdict(int)
+ self._completed_attempts = defaultdict(set)
self._report = report_data
self._report.title = self._report_path.name
self._suite_start_time = time.time()
@@ -105,8 +107,9 @@ def _data_content(self, *args, **kwargs):
def _media_content(self, *args, **kwargs):
pass
- def _process_extras(self, report, test_id):
- test_index = hasattr(report, "rerun") and report.rerun + 1 or 0
+ def _process_extras(self, report, test_id, test_index=None):
+ if test_index is None:
+ test_index = hasattr(report, "rerun") and report.rerun + 1 or 0
report_extras = getattr(report, "extras", [])
for extra_index, extra in enumerate(report_extras):
content = extra["content"]
@@ -180,6 +183,8 @@ def pytest_sessionstart(self, session):
@pytest.hookimpl(trylast=True)
def pytest_sessionfinish(self, session):
+ for nodeid in list(self._reports):
+ self._finalize_attempt(nodeid)
session.config.hook.pytest_html_results_summary(
prefix=self._report.additional_summary["prefix"],
summary=self._report.additional_summary["summary"],
@@ -216,50 +221,111 @@ def pytest_runtest_logreport(self, report):
DeprecationWarning,
)
- # "reruns" makes this code a mess.
- # We store each combination of when and outcome
- # exactly once, unless that outcome is a "rerun"
- # then we store all of them.
- key = (report.when, report.outcome)
- if report.outcome == "rerun":
- if key not in self._reports[report.nodeid]:
- self._reports[report.nodeid][key] = list()
- self._reports[report.nodeid][key].append(report)
- else:
- self._reports[report.nodeid][key] = [report]
-
- finished = report.when == "teardown" and report.outcome != "rerun"
- if not finished:
+ nodeid = report.nodeid
+ rerun = getattr(report, "rerun", None)
+ marker = ("rerun", rerun) if rerun is not None else None
+ current = self._reports.get(nodeid)
+
+ if marker is not None and marker in self._completed_attempts[nodeid]:
return
- # Calculate total duration for a single test.
- # This is needed to add the "teardown" duration
- # to tests total duration.
- test_duration = 0
- for key, reports in self._reports[report.nodeid].items():
- _, outcome = key
- if outcome != "rerun":
- test_duration += reports[0].duration
-
- processed_extras = []
- for key, reports in self._reports[report.nodeid].items():
- when, _ = key
- for each in reports:
- test_id = report.nodeid
- if when != "call":
- test_id += f"::{when}"
- processed_extras += self._process_extras(each, test_id)
-
- for key, reports in self._reports[report.nodeid].items():
- when, _ = key
- for each in reports:
- dur = test_duration if when == "call" else each.duration
- self._process_report(each, dur, processed_extras)
+ if current is not None:
+ if marker is not None and current["marker"] != marker:
+ if (
+ current["marker"][0] == "implicit"
+ and "call" not in current["phases"]
+ ):
+ # The provisional setup belongs to a marked attempt, so
+ # do not consume an unmarked attempt's asset namespace.
+ self._implicit_attempts[nodeid] -= 1
+ current["marker"] = marker
+ current["test_index"] = rerun + 1
+ else:
+ self._finalize_attempt(nodeid)
+ current = None
+ elif (
+ marker is None
+ and report.when == "setup"
+ and ("call" in current["phases"] or "teardown" in current["phases"])
+ ):
+ self._finalize_attempt(nodeid)
+ current = None
+
+ if current is None:
+ if (
+ marker is None
+ and report.when == "teardown"
+ and self._completed_attempts[nodeid]
+ ):
+ # A teardown following a completed attempt cannot reopen it.
+ return
+ index = self._implicit_attempts[nodeid]
+ if marker is None:
+ marker = ("implicit", index)
+ test_index = -index
+ self._implicit_attempts[nodeid] += 1
+ else:
+ test_index = rerun + 1
+ current = {
+ "marker": marker,
+ "test_index": test_index,
+ "phases": set(),
+ "reports": [],
+ }
+ self._reports[nodeid] = current
+
+ current["reports"].append(report)
+ current["phases"].add(report.when)
+ if report.when == "teardown":
+ self._finalize_attempt(nodeid)
+
+ def _finalize_attempt(self, nodeid):
+ attempt = self._reports.pop(nodeid)
+ reports = attempt["reports"]
+ self._completed_attempts[nodeid].add(attempt["marker"])
+ duration = sum(each.duration for each in reports if each.outcome != "rerun")
+
+ def is_visible(each):
+ return each.when in ("call", "collect") or (
+ each.when in ("setup", "teardown") and each.outcome != "passed"
+ )
+
+ visible = [each for each in reports if is_visible(each)]
+ recipient = next((each for each in visible if each.when == "call"), None)
+ if recipient is None:
+ recipient = next((each for each in visible if each.when == "setup"), None)
+ if recipient is None:
+ recipient = next(
+ (each for each in visible if each.when == "teardown"), None
+ )
+
+ report_extras = {id(each): [] for each in reports}
+ for each in reports:
+ target = each if is_visible(each) else recipient
+ if target is None:
+ continue
+ test_id = each.nodeid
+ if each.when != "call":
+ test_id += f"::{each.when}"
+ report_extras[id(target)].extend(
+ self._process_extras(each, test_id, attempt["test_index"])
+ )
+
+ previous_row = None
+ for each in reports:
+ report_duration = duration if each.when == "call" else each.duration
+ row = self._process_report(
+ each, report_duration, report_extras[id(each)], previous_row
+ )
+ if row is not None:
+ previous_row = row
if self._config.getini("generate_report_on_test"):
self._generate_report()
- def _process_report(self, report, duration, processed_extras):
+ def _process_report(
+ self, report, duration, processed_extras, teardown_recipient=None
+ ):
outcome = _process_outcome(report)
try:
# hook returns as list for some reason
@@ -301,7 +367,9 @@ def _process_report(self, report, duration, processed_extras):
report=report, data=processed_logs
)
- self._report.add_test(data, report, outcome, processed_logs)
+ return self._report.add_test(
+ data, report, outcome, processed_logs, teardown_recipient
+ )
def _format_duration(duration):
diff --git a/src/pytest_html/report_data.py b/src/pytest_html/report_data.py
index 3dc0ea21..e1f602db 100644
--- a/src/pytest_html/report_data.py
+++ b/src/pytest_html/report_data.py
@@ -130,10 +130,10 @@ def total_duration(self, duration):
def set_data(self, key, value):
self._data[key] = value
- def add_test(self, test_data, report, outcome, logs):
- # regardless of pass or fail we must add teardown logging to "call"
+ def add_test(self, test_data, report, outcome, logs, teardown_recipient=None):
+ # Captured teardown sections belong to an earlier row of this attempt.
if report.when == "teardown":
- self.append_teardown_log(report)
+ self.append_teardown_log(report, teardown_recipient)
# passed "setup" and "teardown" are not added to the html
if report.when in ["call", "collect"] or (
@@ -142,14 +142,13 @@ def add_test(self, test_data, report, outcome, logs):
test_data["log"] = _handle_ansi("\n".join(logs))
self.outcomes = outcome
self._data["tests"][report.nodeid].append(test_data)
+ return test_data
- def append_teardown_log(self, report):
+ def append_teardown_log(self, report, recipient):
log = []
- if self._data["tests"][report.nodeid]:
- # Last index is "call"
- test = self._data["tests"][report.nodeid][-1]
+ if recipient is not None:
for section in report.sections:
header, content = map(escape, section)
if "teardown" in header:
log.append(f"{' ' + header + ' ':-^80}\n{content}")
- test["log"] += _handle_ansi("\n".join(log))
+ recipient["log"] += _handle_ansi("\n".join(log))
diff --git a/testing/test_phase_attempts.py b/testing/test_phase_attempts.py
new file mode 100644
index 00000000..379496e7
--- /dev/null
+++ b/testing/test_phase_attempts.py
@@ -0,0 +1,440 @@
+import base64
+import json
+from bs4 import BeautifulSoup
+import pytest
+
+pytest_plugins = ("pytester",)
+
+
+@pytest.mark.parametrize("mode", ["assets", "self-contained"])
+@pytest.mark.parametrize(
+ "case",
+ [
+ "pass",
+ "teardown_error",
+ "setup_error",
+ "both_errors",
+ "setup_skip",
+ "call_fail",
+ "call_skip",
+ "rerun",
+ ],
+)
+def test_phase_extras_public_html(pytester, case, mode):
+ pytester.makeconftest(
+ """
+ import pytest
+ from pytest_html import extras
+
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_runtest_makereport(item, call):
+ outcome = yield
+ report = outcome.get_result()
+ attempt = getattr(item, "execution_count", 1)
+ report.extras = [extras.html(f'phase-{report.when}-attempt-{attempt}')]
+ """
+ )
+ setup = (
+ "raise RuntimeError('setup')"
+ if case in ("setup_error", "both_errors")
+ else "pytest.skip('setup skip')" if case == "setup_skip" else "pass"
+ )
+ cleanup = (
+ "raise RuntimeError('teardown')"
+ if case in ("teardown_error", "both_errors")
+ else "pass"
+ )
+ call = (
+ "pytest.skip('call skip')"
+ if case == "call_skip"
+ else "assert False" if case in ("call_fail", "rerun") else "assert True"
+ )
+ if case == "rerun":
+ code = """
+ import pytest
+ state = {'n': 0}
+ @pytest.mark.flaky(reruns=1)
+ def test_case():
+ state['n'] += 1
+ assert state['n'] == 2
+ """
+ else:
+ code = f"""
+ import pytest
+ @pytest.fixture
+ def thing():
+ {setup}
+ yield
+ {cleanup}
+
+ def test_case(thing):
+ {call}
+ """
+ pytester.makepyfile(code)
+ file = pytester.path / "result.html"
+ flags = ["--self-contained-html"] if mode == "self-contained" else []
+ result = pytester.runpytest("--html", str(file), *flags)
+ if case == "pass":
+ result.assert_outcomes(passed=1)
+ assert file.exists()
+ data = json.loads(
+ BeautifulSoup(file.read_text(), "html.parser").select_one("#data-container")[
+ "data-jsonblob"
+ ]
+ )
+ rows = next(iter(data["tests"].values()))
+ got = [[x["content"] for x in row["extras"]] for row in rows]
+
+ def phase(name, attempt=1):
+ return f"phase-{name}-attempt-{attempt}"
+
+ expected = {
+ "pass": [[phase("setup"), phase("call"), phase("teardown")]],
+ "teardown_error": [[phase("setup"), phase("call")], [phase("teardown")]],
+ "setup_error": [[phase("setup"), phase("teardown")]],
+ # The failing setup prevents executing the fixture's teardown body,
+ # although pytest still emits a successful teardown report.
+ "both_errors": [[phase("setup"), phase("teardown")]],
+ "setup_skip": [[phase("setup"), phase("teardown")]],
+ "call_fail": [[phase("setup"), phase("call"), phase("teardown")]],
+ "call_skip": [[phase("setup"), phase("call"), phase("teardown")]],
+ "rerun": [
+ [phase("setup"), phase("call")],
+ [phase("setup", 2), phase("call", 2), phase("teardown", 2)],
+ ],
+ }[case]
+ assert got == expected, f"{case} {mode}: expected {expected}, got {got}"
+
+
+@pytest.mark.parametrize("mode", ["assets", "self-contained"])
+def test_no_cross_phase_asset_rows(pytester, mode):
+ pytester.makeconftest(
+ """
+ import pytest
+ from pytest_html import extras
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_runtest_makereport(item, call):
+ result = yield
+ report = result.get_result()
+ report.extras = [extras.text(f'phase-specific-{report.when}'),
+ extras.json({'phase': report.when})]
+ """
+ )
+ pytester.makepyfile(
+ """
+ import pytest
+ @pytest.fixture
+ def broken_teardown():
+ yield
+ assert False
+ def test_ok(broken_teardown):
+ pass
+ """
+ )
+ file = pytester.path / "assets-report.html"
+ flags = ["--self-contained-html"] if mode == "self-contained" else []
+ pytester.runpytest("--html", str(file), *flags)
+ data = json.loads(
+ BeautifulSoup(file.read_text(), "html.parser").select_one("#data-container")[
+ "data-jsonblob"
+ ]
+ )
+ rows = next(iter(data["tests"].values()))
+ assert len(rows) == 2
+ call, teardown = rows
+ assert len(call["extras"]) == 4, f"call extras unexpected: {call['extras']}"
+ assert len(teardown["extras"]) == 2
+ assert all(
+ "teardown" not in extra["content"] for extra in call["extras"]
+ ), "extra from visible failed teardown leaked to call"
+ assert all(
+ "call" not in extra["content"] for extra in teardown["extras"]
+ ), "extra from call leaked to teardown"
+ if mode == "assets":
+ for row in rows:
+ for extra in row["extras"]:
+ path = pytester.path / extra["content"]
+ assert path.exists(), path
+ assert path.read_bytes(), path
+ else:
+ for row in rows:
+ for extra in row["extras"]:
+ assert extra["content"].startswith("data:"), extra["content"]
+
+
+@pytest.mark.parametrize("mode", ["assets", "self-contained"])
+@pytest.mark.parametrize(
+ ("phases", "expected_results", "owned_extras", "expected_logs"),
+ [
+ (
+ [
+ ("A", "setup", "passed"),
+ ("A", "call", "passed"),
+ ("B", "setup", "passed"),
+ ("B", "call", "passed"),
+ ("B", "teardown", "passed"),
+ ],
+ ["Passed", "Passed"],
+ [["A-setup", "A-call"], ["B-setup", "B-call", "B-teardown"]],
+ [[], ["B"]],
+ ),
+ (
+ [
+ ("A", "setup", "passed"),
+ ("A", "call", "failed"),
+ ("B", "setup", "passed"),
+ ("B", "call", "passed"),
+ ("B", "teardown", "passed"),
+ ],
+ ["Failed", "Passed"],
+ [["A-setup", "A-call"], ["B-setup", "B-call", "B-teardown"]],
+ [[], ["B"]],
+ ),
+ (
+ [
+ ("A", "setup", "passed"),
+ ("A", "call", "passed"),
+ ("B", "setup", "passed"),
+ ("B", "teardown", "passed"),
+ ],
+ ["Passed"],
+ [["A-setup", "A-call"]],
+ [[]],
+ ),
+ (
+ [
+ ("A", "setup", "passed"),
+ ("A", "call", "passed"),
+ ("B", "setup", "failed"),
+ ("B", "teardown", "passed"),
+ ],
+ ["Passed", "Error"],
+ [["A-setup", "A-call"], ["B-setup", "B-teardown"]],
+ [[], ["B"]],
+ ),
+ (
+ [("A", "setup", "passed")],
+ [],
+ [],
+ [],
+ ),
+ (
+ [("A", "setup", "failed"), ("A", "teardown", "failed")],
+ ["Error", "Error"],
+ [["A-setup"], ["A-teardown"]],
+ [["A"], ["A"]],
+ ),
+ (
+ [("A", "teardown", "failed")],
+ ["Error"],
+ [["A-teardown"]],
+ [["A"]],
+ ),
+ (
+ [
+ ("A", "setup", "passed"),
+ ("A", "call", "passed"),
+ ("A", "teardown", "passed"),
+ ("A", "teardown", "passed"),
+ ],
+ ["Passed"],
+ [["A-setup", "A-call", "A-teardown"]],
+ [["A"]],
+ ),
+ (
+ [
+ ("A", "setup", "passed"),
+ ("A", "call", "passed"),
+ ("A", "teardown", "passed"),
+ ("B", "setup", "passed"),
+ ("B", "call", "passed"),
+ ("B", "teardown", "passed"),
+ ],
+ ["Passed", "Passed"],
+ [["A-setup", "A-call", "A-teardown"], ["B-setup", "B-call", "B-teardown"]],
+ [["A"], ["B"]],
+ ),
+ ],
+)
+def test_synthetic_phase_stream(
+ pytester, mode, phases, expected_results, owned_extras, expected_logs
+):
+ pytester.makeconftest(
+ f"""
+ import base64
+ import pytest
+ from pytest_html import extras
+
+ PHASES = {phases!r}
+
+ @pytest.hookimpl(tryfirst=True)
+ def pytest_runtest_protocol(item, nextitem):
+ for attempt, phase, outcome in PHASES:
+ report = pytest.TestReport(
+ nodeid=item.nodeid, location=item.location, keywords={{}},
+ outcome=outcome, longrepr=None, when=phase,
+ duration=0.01,
+ sections=[('Captured stdout teardown', f'capture-{{attempt}}')]
+ if phase == 'teardown' else [],
+ )
+ marker = f'{{attempt}}-{{phase}}'
+ report.extras = [
+ extras.html(f'{{marker}}'),
+ extras.text(marker),
+ extras.json({{'marker': marker}}),
+ extras.image(base64.b64encode(marker.encode()).decode(), mime_type='image/png'),
+ ]
+ item.ihook.pytest_runtest_logreport(report=report)
+ return True
+ """
+ )
+ pytester.makepyfile("def test_case(): pass")
+ pytester.makeini("[pytest]\ngenerate_report_on_test = true")
+ report = pytester.path / "report.html"
+ flags = ["--self-contained-html"] if mode == "self-contained" else []
+ pytester.runpytest("--html", str(report), *flags)
+ document = BeautifulSoup(report.read_text(encoding="utf-8"), "html.parser")
+ blob = json.loads(document.select_one("#data-container")["data-jsonblob"])
+ rows = [row for node_rows in blob["tests"].values() for row in node_rows]
+ assert [row["result"] for row in rows] == expected_results
+ assert [
+ [extra["content"] for extra in row["extras"] if extra["format_type"] == "html"]
+ for row in rows
+ ] == [[f"{marker}" for marker in markers] for markers in owned_extras]
+
+ if not expected_results:
+ assert not list((pytester.path / "assets").glob("*__setup_*.txt"))
+
+ for row, expected_markers, log_owners in zip(rows, owned_extras, expected_logs):
+ for attempt in "AB":
+ assert (f"capture-{attempt}" in row["log"]) == (attempt in log_owners)
+ assert len(row["extras"]) == 4 * len(expected_markers)
+ for marker_index, marker in enumerate(expected_markers):
+ phase = marker.split("-", 1)[1]
+ suffix = "0" if marker.startswith("A-") else "-1"
+ basename = "test_synthetic_phase_stream.py__test_case"
+ if phase != "call":
+ basename += f"__{phase}"
+ group = row["extras"][marker_index * 4 : (marker_index + 1) * 4]
+ assert [entry["format_type"] for entry in group] == [
+ "html",
+ "text",
+ "json",
+ "image",
+ ]
+ assert group[0]["content"] == f"{marker}"
+ files = [
+ ("txt", "text/plain", marker.encode()),
+ ("json", "application/json", json.dumps({"marker": marker}).encode()),
+ ("png", "image/png", marker.encode()),
+ ]
+ for extra_index, (
+ entry,
+ (extension, mime_type, expected_bytes),
+ ) in enumerate(zip(group[1:], files), start=1):
+ assert entry["mime_type"] == mime_type
+ expected_name = f"{basename}_{extra_index}_{suffix}.{extension}"
+ if mode == "assets":
+ assert entry["content"] == f"assets/{expected_name}"
+ assert (
+ pytester.path / entry["content"]
+ ).read_bytes() == expected_bytes
+ else:
+ prefix = f"data:{mime_type}"
+ if entry["format_type"] != "image":
+ prefix += ";charset=utf-8"
+ assert entry["content"].startswith(prefix + ";base64,")
+ assert (
+ base64.b64decode(entry["content"].partition(",")[2])
+ == expected_bytes
+ )
+
+
+@pytest.mark.parametrize("mode", ["assets", "self-contained"])
+def test_removed_attempt_row_cannot_receive_teardown_log(pytester, mode):
+ pytester.makeconftest(
+ """
+ import pytest
+
+ @pytest.hookimpl(tryfirst=True)
+ def pytest_runtest_protocol(item, nextitem):
+ for attempt in ('A', 'B'):
+ for phase in ('setup', 'call', 'teardown'):
+ report = pytest.TestReport(
+ nodeid=item.nodeid, location=item.location,
+ keywords={}, outcome='passed', longrepr=None,
+ when=phase, duration=0.01,
+ sections=[('Captured stdout teardown', f'capture-{attempt}')]
+ if phase == 'teardown' else [],
+ )
+ report.attempt_marker = attempt
+ item.ihook.pytest_runtest_logreport(report=report)
+ return True
+
+ def pytest_html_results_table_row(report, cells):
+ if report.when == 'call' and getattr(report, 'attempt_marker', '') == 'B':
+ cells.clear()
+ """
+ )
+ pytester.makepyfile("def test_case(): pass")
+ report = pytester.path / "report.html"
+ flags = ["--self-contained-html"] if mode == "self-contained" else []
+ pytester.runpytest("--html", str(report), *flags)
+ document = BeautifulSoup(report.read_text(encoding="utf-8"), "html.parser")
+ data = json.loads(document.select_one("#data-container")["data-jsonblob"])
+ rows = next(iter(data["tests"].values()))
+ assert len(rows) == 1
+ assert rows[0]["result"] == "Passed"
+ assert "capture-A" in rows[0]["log"]
+ assert "capture-B" not in rows[0]["log"]
+
+
+@pytest.mark.parametrize("mode", ["assets", "self-contained"])
+def test_explicit_retry_does_not_consume_implicit_asset_suffix(pytester, mode):
+ pytester.makeconftest(
+ """
+ import pytest
+ from pytest_html import extras
+
+ @pytest.hookimpl(tryfirst=True)
+ def pytest_runtest_protocol(item, nextitem):
+ for attempt, rerun in enumerate((0, None, None)):
+ for phase in ('setup', 'call', 'teardown'):
+ report = pytest.TestReport(
+ nodeid=item.nodeid, location=item.location,
+ keywords={}, outcome='passed', longrepr=None,
+ when=phase, duration=0.01, sections=[],
+ )
+ # An unmarked setup can precede a plugin-marked call.
+ if rerun is not None and phase != 'setup':
+ report.rerun = rerun
+ report.extras = [extras.text(f'attempt-{attempt}-{phase}')]
+ item.ihook.pytest_runtest_logreport(report=report)
+ return True
+ """
+ )
+ pytester.makepyfile("def test_case(): pass")
+ path = pytester.path / "report.html"
+ flags = ["--self-contained-html"] if mode == "self-contained" else []
+ pytester.runpytest("--html", str(path), *flags)
+ soup = BeautifulSoup(path.read_text(encoding="utf-8"), "html.parser")
+ rows = next(
+ iter(
+ json.loads(soup.select_one("#data-container")["data-jsonblob"])[
+ "tests"
+ ].values()
+ )
+ )
+ assert [row["result"] for row in rows] == ["Passed"] * 3
+ for attempt, (row, suffix) in enumerate(zip(rows, ("1", "0", "-1"))):
+ assert len(row["extras"]) == 3
+ for phase, extra in zip(("setup", "call", "teardown"), row["extras"]):
+ payload = f"attempt-{attempt}-{phase}".encode()
+ if mode == "assets":
+ assert extra["content"].endswith(f"_0_{suffix}.txt")
+ assert (pytester.path / extra["content"]).read_bytes() == payload
+ else:
+ assert extra["content"].startswith(
+ "data:text/plain;charset=utf-8;base64,"
+ )
+ assert base64.b64decode(extra["content"].partition(",")[2]) == payload
From ad7e3ce7efbd4b6a7cc0e73c13fd0f562e478f35 Mon Sep 17 00:00:00 2001
From: contputer0x0 <340606769+contputer0x0@users.noreply.github.com>
Date: Sun, 11 Oct 2026 20:11:24 +0200
Subject: [PATCH 2/4] Keep distinct teardown failures and deduplicate report
deliveries
---
src/pytest_html/basereport.py | 19 ++++--
testing/test_phase_attempts.py | 116 +++++++++++++++++++++++++++++++++
2 files changed, 129 insertions(+), 6 deletions(-)
diff --git a/src/pytest_html/basereport.py b/src/pytest_html/basereport.py
index 9c66fa31..46c3bf49 100644
--- a/src/pytest_html/basereport.py
+++ b/src/pytest_html/basereport.py
@@ -8,6 +8,7 @@
import re
import time
import warnings
+from weakref import WeakSet
from collections import defaultdict
from html import escape
from pathlib import Path
@@ -34,6 +35,10 @@ def __init__(self, report_path, config, report_data, template, css):
self._reports = {}
self._implicit_attempts = defaultdict(int)
self._completed_attempts = defaultdict(set)
+ # Keep report delivery idempotent without retaining every TestReport
+ # after its owner has released it. Distinct phase reports may share a
+ # nodeid, phase and outcome; only object identity proves a replay.
+ self._seen_reports = WeakSet()
self._report = report_data
self._report.title = self._report_path.name
self._suite_start_time = time.time()
@@ -214,6 +219,10 @@ def pytest_collection_finish(self, session):
@pytest.hookimpl(trylast=True)
def pytest_runtest_logreport(self, report):
+ if report in self._seen_reports:
+ return
+ self._seen_reports.add(report)
+
if hasattr(report, "duration_formatter"):
warnings.warn(
"'duration_formatter' has been removed and no longer has any effect!"
@@ -252,12 +261,10 @@ def pytest_runtest_logreport(self, report):
current = None
if current is None:
- if (
- marker is None
- and report.when == "teardown"
- and self._completed_attempts[nodeid]
- ):
- # A teardown following a completed attempt cannot reopen it.
+ if report.when == "teardown" and report.outcome == "passed":
+ # With no open attempt, a passed teardown has no visible row
+ # to receive extras. Do not consume the next attempt's asset
+ # suffix; a separate failed teardown *does* get its own row.
return
index = self._implicit_attempts[nodeid]
if marker is None:
diff --git a/testing/test_phase_attempts.py b/testing/test_phase_attempts.py
index 379496e7..f068ebdb 100644
--- a/testing/test_phase_attempts.py
+++ b/testing/test_phase_attempts.py
@@ -241,6 +241,20 @@ def test_ok(broken_teardown):
[["A-setup", "A-call", "A-teardown"]],
[["A"]],
),
+ (
+ [
+ ("A", "setup", "passed"),
+ ("A", "call", "passed"),
+ ("A", "teardown", "passed"),
+ ("A", "teardown", "passed"),
+ ("B", "setup", "passed"),
+ ("B", "call", "passed"),
+ ("B", "teardown", "passed"),
+ ],
+ ["Passed", "Passed"],
+ [["A-setup", "A-call", "A-teardown"], ["B-setup", "B-call", "B-teardown"]],
+ [["A"], ["B"]],
+ ),
(
[
("A", "setup", "passed"),
@@ -438,3 +452,105 @@ def pytest_runtest_protocol(item, nextitem):
"data:text/plain;charset=utf-8;base64,"
)
assert base64.b64decode(extra["content"].partition(",")[2]) == payload
+
+
+@pytest.mark.parametrize("mode", ["assets", "self-contained"])
+def test_distinct_failed_teardown_after_completed_attempt_is_visible(pytester, mode):
+ pytester.makeconftest(
+ """
+ import pytest
+ from pytest_html import extras
+
+ @pytest.hookimpl(tryfirst=True)
+ def pytest_runtest_protocol(item, nextitem):
+ for when, outcome, label in (
+ ('setup', 'passed', 'first-setup'),
+ ('call', 'passed', 'first-call'),
+ ('teardown', 'passed', 'first-teardown'),
+ ('teardown', 'failed', 'second-teardown'),
+ ):
+ report = pytest.TestReport(
+ nodeid=item.nodeid, location=item.location, keywords={},
+ outcome=outcome, longrepr='distinct teardown failed'
+ if outcome == 'failed' else None,
+ when=when, duration=0.01,
+ sections=[('Captured stdout teardown', label)]
+ if when == 'teardown' else [],
+ )
+ report.extras = [extras.text(label)]
+ item.ihook.pytest_runtest_logreport(report=report)
+ return True
+ """
+ )
+ pytester.makepyfile("def test_case(): pass")
+ pytester.makeini("[pytest]\ngenerate_report_on_test = true")
+ path = pytester.path / "report.html"
+ flags = ["--self-contained-html"] if mode == "self-contained" else []
+ pytester.runpytest("--html", str(path), *flags)
+ document = BeautifulSoup(path.read_text(encoding="utf-8"), "html.parser")
+ data = json.loads(document.select_one("#data-container")["data-jsonblob"])
+ rows = next(iter(data["tests"].values()))
+ assert [row["result"] for row in rows] == ["Passed", "Error"]
+ assert [len(row["extras"]) for row in rows] == [3, 1]
+ assert "first-teardown" in rows[0]["log"]
+ assert "second-teardown" not in rows[0]["log"]
+ assert "second-teardown" in rows[1]["log"]
+ for row, payloads in zip(
+ rows, (("first-setup", "first-call", "first-teardown"), ("second-teardown",))
+ ):
+ for extra, payload in zip(row["extras"], payloads):
+ assert extra["format_type"] == "text"
+ if mode == "assets":
+ assert (
+ pytester.path / extra["content"]
+ ).read_bytes() == payload.encode()
+ else:
+ assert (
+ base64.b64decode(extra["content"].partition(",")[2])
+ == payload.encode()
+ )
+
+
+@pytest.mark.parametrize("mode", ["assets", "self-contained"])
+def test_replaying_same_call_report_object_is_exactly_once(pytester, mode):
+ pytester.makeconftest(
+ """
+ import pytest
+ from pytest_html import extras
+
+ @pytest.hookimpl(tryfirst=True)
+ def pytest_runtest_protocol(item, nextitem):
+ for when in ('setup', 'call', 'teardown'):
+ report = pytest.TestReport(
+ nodeid=item.nodeid, location=item.location, keywords={},
+ outcome='passed', longrepr=None, when=when, duration=0.01,
+ sections=[],
+ )
+ report.extras = [extras.text(f'once-{when}')]
+ item.ihook.pytest_runtest_logreport(report=report)
+ if when == 'call':
+ # A hook redelivers the identical report object before teardown.
+ item.ihook.pytest_runtest_logreport(report=report)
+ return True
+ """
+ )
+ pytester.makepyfile("def test_case(): pass")
+ pytester.makeini("[pytest]\ngenerate_report_on_test = true")
+ path = pytester.path / "report.html"
+ flags = ["--self-contained-html"] if mode == "self-contained" else []
+ pytester.runpytest("--html", str(path), *flags)
+ document = BeautifulSoup(path.read_text(encoding="utf-8"), "html.parser")
+ data = json.loads(document.select_one("#data-container")["data-jsonblob"])
+ rows = next(iter(data["tests"].values()))
+ assert [row["result"] for row in rows] == ["Passed"]
+ assert len(rows[0]["extras"]) == 3
+ for extra, payload in zip(
+ rows[0]["extras"], ("once-setup", "once-call", "once-teardown")
+ ):
+ assert extra["format_type"] == "text"
+ if mode == "assets":
+ assert (pytester.path / extra["content"]).read_bytes() == payload.encode()
+ else:
+ assert (
+ base64.b64decode(extra["content"].partition(",")[2]) == payload.encode()
+ )
From 440df22b504e0004a996c92160d416f91e1c3fa0 Mon Sep 17 00:00:00 2001
From: contputer0x0 <340606769+contputer0x0@users.noreply.github.com>
Date: Sun, 11 Oct 2026 20:45:59 +0200
Subject: [PATCH 3/4] Assert rerun extras against emitted phase reports
---
testing/test_phase_attempts.py | 35 ++++++++++++++++++++++++++++++----
1 file changed, 31 insertions(+), 4 deletions(-)
diff --git a/testing/test_phase_attempts.py b/testing/test_phase_attempts.py
index f068ebdb..2fa9ad38 100644
--- a/testing/test_phase_attempts.py
+++ b/testing/test_phase_attempts.py
@@ -23,6 +23,7 @@
def test_phase_extras_public_html(pytester, case, mode):
pytester.makeconftest(
"""
+ import json
import pytest
from pytest_html import extras
@@ -31,7 +32,15 @@ def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
attempt = getattr(item, "execution_count", 1)
+ report._html_test_attempt = attempt
report.extras = [extras.html(f'phase-{report.when}-attempt-{attempt}')]
+
+ @pytest.hookimpl(tryfirst=True)
+ def pytest_runtest_logreport(report):
+ attempt = getattr(report, "_html_test_attempt", None)
+ if attempt is not None:
+ with open("phase_emissions.jsonl", "a", encoding="utf-8") as stream:
+ stream.write(json.dumps({"attempt": attempt, "when": report.when}) + "\\n")
"""
)
setup = (
@@ -98,11 +107,29 @@ def phase(name, attempt=1):
"setup_skip": [[phase("setup"), phase("teardown")]],
"call_fail": [[phase("setup"), phase("call"), phase("teardown")]],
"call_skip": [[phase("setup"), phase("call"), phase("teardown")]],
- "rerun": [
- [phase("setup"), phase("call")],
- [phase("setup", 2), phase("call", 2), phase("teardown", 2)],
- ],
+ "rerun": None,
}[case]
+ if case == "rerun":
+ # pytest-rerunfailures versions differ on whether the first attempt's
+ # successful teardown reaches pytest_runtest_logreport. Assert every
+ # *emitted* phase survives exactly once in the owning attempt's row.
+ events = [
+ json.loads(line)
+ for line in (pytester.path / "phase_emissions.jsonl")
+ .read_text()
+ .splitlines()
+ ]
+ by_attempt = [
+ [event["when"] for event in events if event["attempt"] == attempt]
+ for attempt in (1, 2)
+ ]
+ assert by_attempt[0] in (["setup", "call"], ["setup", "call", "teardown"])
+ assert by_attempt[1] == ["setup", "call", "teardown"]
+ assert [row["result"] for row in rows] == ["Rerun", "Passed"]
+ expected = [
+ [phase(when, attempt) for when in phases]
+ for attempt, phases in enumerate(by_attempt, start=1)
+ ]
assert got == expected, f"{case} {mode}: expected {expected}, got {got}"
From 57365226e93c86e80d6ad9cc5f6f43173654dd6e Mon Sep 17 00:00:00 2001
From: contputer0x0 <340606769+contputer0x0@users.noreply.github.com>
Date: Sun, 11 Oct 2026 22:19:56 +0200
Subject: [PATCH 4/4] style: align import ordering with pinned pre-commit hook
---
src/pytest_html/basereport.py | 2 +-
testing/test_phase_attempts.py | 3 ++-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/pytest_html/basereport.py b/src/pytest_html/basereport.py
index 46c3bf49..979e1be6 100644
--- a/src/pytest_html/basereport.py
+++ b/src/pytest_html/basereport.py
@@ -8,10 +8,10 @@
import re
import time
import warnings
-from weakref import WeakSet
from collections import defaultdict
from html import escape
from pathlib import Path
+from weakref import WeakSet
import pytest
diff --git a/testing/test_phase_attempts.py b/testing/test_phase_attempts.py
index 2fa9ad38..4a39a431 100644
--- a/testing/test_phase_attempts.py
+++ b/testing/test_phase_attempts.py
@@ -1,7 +1,8 @@
import base64
import json
-from bs4 import BeautifulSoup
+
import pytest
+from bs4 import BeautifulSoup
pytest_plugins = ("pytester",)