pytest-better-report 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ from pytest_better_report.const import EXECUTION_RESULTS_FILENAME, LOGGER_NAME, TEST_RESULTS_FILENAME
2
+
3
+ __all__ = ["EXECUTION_RESULTS_FILENAME", "LOGGER_NAME", "TEST_RESULTS_FILENAME"]
@@ -0,0 +1,191 @@
1
+ """
2
+ CLI tool to compare two better-report JSON log files.
3
+
4
+ Checks if all pytest_test_name entries are equal, lists missing tests from each file,
5
+ and for matching tests compares their test_status and lists all diffs.
6
+
7
+ Usage:
8
+ python better_report_compare.py -a <file_a.json> -b <file_b.json> [--fail-on-diff]
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from python_base_command import BaseCommand, CommandError, CommandParser
19
+
20
+ PASS_SYMBOL = "✓"
21
+ FAIL_SYMBOL = "✗"
22
+ DIFF_SYMBOL = "≠"
23
+
24
+ SEP = "─" * 100
25
+
26
+
27
+ def _log_missing(missing: list[str], source: str, target: str) -> None:
28
+ if missing:
29
+ print(f"\n{FAIL_SYMBOL} Tests in {source} but MISSING from {target} ({len(missing)})")
30
+ for name in missing:
31
+ print(f" - {name}")
32
+ else:
33
+ print(f"\n{PASS_SYMBOL} No tests missing from {target}")
34
+
35
+
36
+ def _log_status_diffs(status_diffs: list[tuple[str, str, str]]) -> None:
37
+ if status_diffs:
38
+ col_w = max(len(name) for name, _, _ in status_diffs)
39
+ print(f"\n{DIFF_SYMBOL} Status differences in common tests ({len(status_diffs)})")
40
+ print(f" {'TEST NAME':<{col_w}} {'STATUS IN A':<12} STATUS IN B")
41
+ print(" " + "-" * (col_w + 28))
42
+ for name, s_a, s_b in status_diffs:
43
+ print(f" {name:<{col_w}} {s_a:<12} {s_b}")
44
+ else:
45
+ print(f"\n{PASS_SYMBOL} All common tests have identical statuses")
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ComparisonSummary:
50
+ file_a: Path
51
+ file_b: Path
52
+ total_a: int
53
+ total_b: int
54
+ common_count: int
55
+ only_in_a: list[str]
56
+ only_in_b: list[str]
57
+ status_diffs: list[tuple[str, str, str]]
58
+ test_file_name: str | None = None
59
+
60
+ @property
61
+ def identical(self) -> bool:
62
+ return not (self.only_in_a or self.only_in_b or self.status_diffs)
63
+
64
+
65
+ def _log_summary(summary: ComparisonSummary) -> None:
66
+ print(f"\n{SEP}")
67
+ print(" BETTER-REPORT COMPARISON")
68
+ print(SEP)
69
+ print(f" File A : {summary.file_a}")
70
+ print(f" File B : {summary.file_b}")
71
+ if summary.test_file_name:
72
+ print(f" Filter : test_file_name = {summary.test_file_name}")
73
+ print(f" Tests : {summary.total_a} in A | {summary.total_b} in B | {summary.common_count} in common")
74
+ print(SEP)
75
+
76
+ _log_missing(summary.only_in_a, source="A", target="B")
77
+ _log_missing(summary.only_in_b, source="B", target="A")
78
+ _log_status_diffs(summary.status_diffs)
79
+
80
+ print(f"\n{SEP}")
81
+ if not summary.identical:
82
+ print(" RESULT: DIFFERENCES FOUND")
83
+ else:
84
+ print(" RESULT: REPORTS ARE IDENTICAL")
85
+ print(f"{SEP}\n")
86
+
87
+
88
+ def load_report(path: Path, test_file_name: str | None = None) -> dict[str, dict]:
89
+ with open(path) as f:
90
+ raw = json.load(f)
91
+ indexed: dict[str, dict] = {}
92
+ for entry in raw.values():
93
+ if test_file_name and entry.get("test_file_name") != test_file_name:
94
+ continue
95
+ if pytest_name := entry.get("pytest_test_name"):
96
+ indexed[pytest_name] = entry
97
+ return indexed
98
+
99
+
100
+ def compare_reports(file_a: Path, file_b: Path, test_file_name: str | None = None) -> bool:
101
+ report_a = load_report(file_a, test_file_name)
102
+ report_b = load_report(file_b, test_file_name)
103
+
104
+ names_a = set(report_a.keys())
105
+ names_b = set(report_b.keys())
106
+
107
+ only_in_a = sorted(names_a - names_b)
108
+ only_in_b = sorted(names_b - names_a)
109
+ common = sorted(names_a & names_b)
110
+
111
+ status_diffs = [
112
+ (name, report_a[name]["test_status"], report_b[name]["test_status"])
113
+ for name in common
114
+ if report_a[name].get("test_status") != report_b[name].get("test_status")
115
+ ]
116
+
117
+ summary = ComparisonSummary(
118
+ file_a=file_a,
119
+ file_b=file_b,
120
+ total_a=len(names_a),
121
+ total_b=len(names_b),
122
+ common_count=len(common),
123
+ only_in_a=only_in_a,
124
+ only_in_b=only_in_b,
125
+ status_diffs=status_diffs,
126
+ test_file_name=test_file_name,
127
+ )
128
+ _log_summary(summary=summary)
129
+ return summary.identical
130
+
131
+
132
+ class Command(BaseCommand):
133
+ help = "Compare two better-report JSON log files for test coverage and status differences."
134
+ version = "1.0.0"
135
+
136
+ def add_arguments(self, parser: CommandParser) -> None:
137
+ parser.add_argument(
138
+ "-a",
139
+ "--file-a",
140
+ type=Path,
141
+ required=True,
142
+ help="Path to the first better-report JSON file.",
143
+ )
144
+ parser.add_argument(
145
+ "-b",
146
+ "--file-b",
147
+ type=Path,
148
+ required=True,
149
+ help="Path to the second better-report JSON file.",
150
+ )
151
+ parser.add_argument(
152
+ "--fail-on-diff",
153
+ action="store_true",
154
+ default=False,
155
+ help="Exit with non-zero code if any differences are found.",
156
+ )
157
+ parser.add_argument(
158
+ "--test-file-name",
159
+ type=str,
160
+ required=False,
161
+ default=None,
162
+ help="Filter comparison to tests belonging to this test_file_name "
163
+ "(e.g. test_advertiser_services_missing.py).",
164
+ )
165
+
166
+ def handle(self, **kwargs: Any) -> None:
167
+ file_a: Path = kwargs["file_a"]
168
+ file_b: Path = kwargs["file_b"]
169
+ fail_on_diff: bool = kwargs["fail_on_diff"]
170
+ test_file_name: str | None = kwargs.get("test_file_name")
171
+
172
+ for label, path in (("A", file_a), ("B", file_b)):
173
+ if not path.exists():
174
+ raise FileNotFoundError(f"File {label} not found: {path}")
175
+
176
+ identical = compare_reports(file_a, file_b, test_file_name)
177
+
178
+ if fail_on_diff and not identical:
179
+ raise CommandError("Reports have differences.")
180
+
181
+
182
+ def main() -> None:
183
+ """Entry point: run the compare command from CLI."""
184
+ print('Starting "Better Report Compare"')
185
+
186
+ os.environ["PYTHON_BASE_COMMAND_LOG_FILE"] = "false"
187
+ Command().run_from_argv(argv=sys.argv)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
@@ -0,0 +1,8 @@
1
+ LOGGER_NAME = "pytest_better_report"
2
+
3
+ EXECUTION_RESULTS_FILENAME = "execution_results.json"
4
+ TEST_RESULTS_FILENAME = "test_results.json"
5
+ MD_REPORT_FILENAME = "test_report.md"
6
+ OUTPUT_DIR_NAME = "results_output"
7
+
8
+ REPORTER_PLUGIN_NAME = "better-report-reporter"
@@ -0,0 +1,5 @@
1
+ from pytest_better_report.models.execution_data import ExecutionData
2
+ from pytest_better_report.models.status import ExecutionStatus
3
+ from pytest_better_report.models.test_data import TestData
4
+
5
+ __all__ = ["ExecutionStatus", "ExecutionData", "TestData"]
@@ -0,0 +1,7 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class EnvironmentData:
6
+ python_version: str | None
7
+ platform: str | None
@@ -0,0 +1,20 @@
1
+ from dataclasses import dataclass
2
+
3
+ from pytest_better_report.models.status import ExecutionStatus
4
+
5
+
6
+ @dataclass
7
+ class ExecutionData:
8
+ execution_status: ExecutionStatus
9
+ revision: str | None
10
+ execution_start_time: str | None = None
11
+ execution_end_time: str | None = None
12
+ execution_duration_sec: str | None = None
13
+
14
+ repo_name: str | None = None
15
+ pull_request_number: str | None = None
16
+ merge_request_number: str | None = None
17
+ pipeline_number: str | None = None
18
+ commit: str | None = None
19
+
20
+ test_list: list | None = None
@@ -0,0 +1,13 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class ExecutionStatus(StrEnum):
5
+ COLLECTED = "collected"
6
+ STARTED = "started"
7
+ PENDING = "pending"
8
+ PASSED = "passed"
9
+ FAILED = "failed"
10
+ XFAIL = "xfailed"
11
+ XPASS = "xpassed"
12
+ CANCELLED = "cancelled"
13
+ SKIPPED = "skipped"
@@ -0,0 +1,24 @@
1
+ from dataclasses import dataclass
2
+
3
+ from pytest_better_report.models.status import ExecutionStatus
4
+
5
+
6
+ @dataclass
7
+ class TestData:
8
+ __test__ = False # a data model, not a test class (stops pytest from trying to collect it)
9
+
10
+ test_file_name: str
11
+ class_test_name: str | None
12
+ test_name: str
13
+ pytest_test_name: str
14
+ test_full_name: str
15
+ test_full_path: str
16
+ test_status: ExecutionStatus = ExecutionStatus.COLLECTED
17
+ test_parameters: dict[str, object] | None = None
18
+ test_markers: list | None = None
19
+ test_start_time: str | None = None
20
+ test_end_time: str | None = None
21
+ test_duration_sec: float | None = None # only for the tst itself, not including fixtures
22
+ exception_message: dict | None = None
23
+ run_index: int | None = None
24
+ test_user_properties: dict[str, object] | None = None
@@ -0,0 +1,324 @@
1
+ """Structured JSON (and optional Markdown) reporting of a pytest run: ``--better-report``.
2
+
3
+ Without ``--better-report`` the reporter is never registered, so none of its hooks run.
4
+
5
+ How results are recorded:
6
+ - A test's status is decided from pytest's **final** reports (``pytest_runtest_logreport``), after every plugin has
7
+ finished changing them (e.g. one that converts failures into skips), so the result doesn't depend on plugin
8
+ load order.
9
+ - The exception details come from the raw call information, captured in a plain ``pytest_runtest_makereport``
10
+ wrapper. It never reads or decides the outcome there, only the exception, so its order doesn't matter either.
11
+ - All state lives on a reporter object created for each run, so nothing leaks between runs in the same process.
12
+ """
13
+
14
+ import json
15
+ import platform
16
+ import sys
17
+ from collections.abc import Generator
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import pytest
23
+ from custom_python_logger import get_logger
24
+ from python_base_toolkit.utils.data_serialization import default_serialize
25
+
26
+ from pytest_better_report.const import (
27
+ EXECUTION_RESULTS_FILENAME,
28
+ LOGGER_NAME,
29
+ MD_REPORT_FILENAME,
30
+ OUTPUT_DIR_NAME,
31
+ REPORTER_PLUGIN_NAME,
32
+ TEST_RESULTS_FILENAME,
33
+ )
34
+ from pytest_better_report.models import ExecutionData, ExecutionStatus, TestData
35
+ from pytest_better_report.models.environment_data import EnvironmentData
36
+ from pytest_better_report.utils.create_report import generate_md_report
37
+ from pytest_better_report.utils.helper import save_as_json, save_as_markdown
38
+ from pytest_better_report.utils.pytest_helper import (
39
+ get_pytest_test_name,
40
+ get_test_full_name,
41
+ get_test_full_path,
42
+ get_test_name_without_parameters,
43
+ )
44
+
45
+ logger = get_logger(f"{LOGGER_NAME}.better_report")
46
+
47
+ _PASSING_STATUSES = (
48
+ ExecutionStatus.COLLECTED,
49
+ ExecutionStatus.PASSED,
50
+ ExecutionStatus.SKIPPED,
51
+ ExecutionStatus.XFAIL,
52
+ )
53
+
54
+
55
+ def pytest_addoption(parser: pytest.Parser) -> None:
56
+ parser.addoption(
57
+ "--better-report",
58
+ action="store_true",
59
+ default=False,
60
+ help="Enable the pytest-better-report plugin",
61
+ )
62
+ parser.addoption(
63
+ "--output-dir",
64
+ type=Path,
65
+ action="store",
66
+ default=None,
67
+ help="Directory to save the results and reports",
68
+ )
69
+ parser.addoption("--traceback", action="store_true", default=False, help="Enable detailed traceback in the report")
70
+ parser.addoption(
71
+ "--md-report", action="store_true", default=False, help="Generate a markdown report of the test results"
72
+ )
73
+ parser.addoption("--repo-name", action="store", default=None, help="Git Repository Name")
74
+ parser.addoption("--pr-number", action="store", default=None, help="Pull Request Number")
75
+ parser.addoption("--mr-number", action="store", default=None, help="Merge Request Number")
76
+ parser.addoption("--pipeline-number", action="store", default=None, help="CI Pipeline Number")
77
+ parser.addoption(
78
+ "--commit",
79
+ action="store",
80
+ default=None,
81
+ help="Commit Hash (SHA-1) of the repository at the time of test execution",
82
+ )
83
+ parser.addoption(
84
+ "--add-parameters",
85
+ action="store_true",
86
+ default=None,
87
+ help='Add the test parameters as fields to the "test_results.json" file',
88
+ )
89
+ parser.addoption(
90
+ "--pytest-command",
91
+ action="store_true",
92
+ default=None,
93
+ help='Add the detailed information about the pytest command-line to the "execution_results.json" file',
94
+ )
95
+ parser.addoption(
96
+ "--pytest-xfail-strict",
97
+ action="store_true",
98
+ default=False,
99
+ help="Enable strict xfail handling, treating unexpected passes as failures, if set to True "
100
+ '"execution status" will be "failed" when there is at least one xpass test',
101
+ )
102
+ parser.addoption(
103
+ "--result-each-test",
104
+ action="store_true",
105
+ default=False,
106
+ help="Print the pytest result for each test after its execution",
107
+ )
108
+ parser.addoption(
109
+ "--log-collected-tests",
110
+ action="store_true",
111
+ default=False,
112
+ help="Log all collected tests at the start of the test session",
113
+ )
114
+
115
+
116
+ @pytest.hookimpl(tryfirst=True)
117
+ def pytest_configure(config: pytest.Config) -> None:
118
+ if not config.getoption("--better-report"):
119
+ return
120
+
121
+ if config.option.output_dir:
122
+ config.option.output_dir = config.option.output_dir / OUTPUT_DIR_NAME
123
+ else:
124
+ config.option.output_dir = Path(OUTPUT_DIR_NAME)
125
+
126
+ config.pluginmanager.register(BetterReporter(config=config), REPORTER_PLUGIN_NAME)
127
+
128
+
129
+ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
130
+ # Gated by its own flag, independent of --better-report (as it always was).
131
+ if not config.getoption("--log-collected-tests"):
132
+ return
133
+
134
+ for item in items:
135
+ logger.debug(f"Collected test: {get_test_full_name(item=item)}")
136
+
137
+
138
+ def _iso(timestamp: float) -> str:
139
+ return datetime.fromtimestamp(timestamp, UTC).isoformat()
140
+
141
+
142
+ def _status_from_report(report: pytest.TestReport) -> ExecutionStatus | None:
143
+ """The status a single phase report implies, or None if it doesn't change the test's status."""
144
+ if hasattr(report, "wasxfail") and report.skipped:
145
+ return ExecutionStatus.XFAIL
146
+ if hasattr(report, "wasxfail") and report.passed:
147
+ return ExecutionStatus.XPASS
148
+ if report.failed:
149
+ return ExecutionStatus.FAILED
150
+ if report.skipped:
151
+ return ExecutionStatus.SKIPPED
152
+ if report.when == "call" and report.passed:
153
+ return ExecutionStatus.PASSED
154
+ return None
155
+
156
+
157
+ class BetterReporter:
158
+ """Registered only when ``--better-report`` is set; one instance per run holds all of the run's state."""
159
+
160
+ def __init__(self, config: pytest.Config) -> None:
161
+ self._config = config
162
+ self.execution_results: dict[str, Any] = {}
163
+ self.test_results: dict[str, TestData] = {}
164
+ self._full_names: dict[str, str] = {} # nodeid -> key in test_results
165
+ self._setup_stop: dict[str, float] = {}
166
+ self._tests_started = False
167
+
168
+ def _get_test_data(self, nodeid: str) -> TestData | None:
169
+ if (full_name := self._full_names.get(nodeid)) is None:
170
+ return None
171
+ return self.test_results.get(full_name)
172
+
173
+ def pytest_sessionstart(self, session: pytest.Session) -> None:
174
+ logger.info("Better report plugin enabled.")
175
+ logger.info(f"Output directory set to: {session.config.option.output_dir}")
176
+
177
+ self.execution_results["environment_info"] = EnvironmentData(
178
+ python_version=platform.python_version(),
179
+ platform=platform.platform(),
180
+ )
181
+ self.execution_results["execution_info"] = ExecutionData(
182
+ execution_status=ExecutionStatus.STARTED,
183
+ revision=datetime.now(UTC).strftime("%Y%m%d%H%M%S%f"),
184
+ execution_start_time=datetime.now(UTC).isoformat(),
185
+ repo_name=session.config.getoption("--repo-name", None),
186
+ pull_request_number=session.config.getoption("--pr-number", None),
187
+ merge_request_number=session.config.getoption("--mr-number", None),
188
+ pipeline_number=session.config.getoption("--pipeline-number", None),
189
+ commit=session.config.getoption("--commit", None),
190
+ )
191
+ if session.config.getoption("--pytest-command"):
192
+ self.execution_results["pytest_command"] = {
193
+ "real_cli": sys.argv,
194
+ "ini_addopts": session.config.getini("addopts"),
195
+ "raw_args": session.config.invocation_params.args,
196
+ }
197
+
198
+ def pytest_collection_finish(self, session: pytest.Session) -> None:
199
+ add_parameters = session.config.getoption("--add-parameters")
200
+ for item in session.items:
201
+ test_full_name = get_test_full_name(item=item)
202
+ has_callspec_params = bool(getattr(item, "callspec", None) and getattr(item.callspec, "params", None))
203
+ self._full_names[item.nodeid] = test_full_name
204
+ self.test_results[test_full_name] = TestData(
205
+ class_test_name=item.cls.__name__ if item.cls else None,
206
+ test_name=get_test_name_without_parameters(item=item),
207
+ pytest_test_name=get_pytest_test_name(item=item),
208
+ test_full_name=test_full_name,
209
+ test_full_path=get_test_full_path(item=item),
210
+ test_file_name=item.path.name,
211
+ test_parameters=item.callspec.params if has_callspec_params else None,
212
+ test_markers=[marker.name for marker in item.iter_markers() if not marker.args],
213
+ test_status=ExecutionStatus.COLLECTED,
214
+ test_start_time=None,
215
+ run_index=len(self.test_results) + 1,
216
+ )
217
+ if has_callspec_params and add_parameters:
218
+ self.test_results[test_full_name].__dict__.update(**item.callspec.params)
219
+ logger.debug(
220
+ f"Tests to be executed: \n{json.dumps(list(self.test_results.keys()), indent=4, default=default_serialize)}"
221
+ )
222
+
223
+ # Not tryfirst, and it never reads report.outcome: it only captures the raw exception of each phase.
224
+ @pytest.hookimpl(wrapper=True)
225
+ def pytest_runtest_makereport(
226
+ self, item: pytest.Item, call: pytest.CallInfo[None]
227
+ ) -> Generator[None, pytest.TestReport, pytest.TestReport]:
228
+ report = yield
229
+ if (test_item := self._get_test_data(item.nodeid)) is None:
230
+ return report
231
+
232
+ if call.excinfo is None:
233
+ if call.when == "call":
234
+ test_item.exception_message = None
235
+ return report
236
+
237
+ # The first failing phase wins: a teardown error doesn't hide the call's failure.
238
+ if call.when != "teardown" or test_item.exception_message is None:
239
+ test_item.exception_message = _exception_message(
240
+ call=call, with_traceback=item.config.getoption("--traceback")
241
+ )
242
+ return report
243
+
244
+ def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
245
+ if (test_item := self._get_test_data(report.nodeid)) is None:
246
+ logger.warning(f"Test {report.nodeid} missing in test_results")
247
+ return
248
+
249
+ if report.when == "setup":
250
+ self._tests_started = True
251
+ self._setup_stop[report.nodeid] = report.stop
252
+ test_item.test_start_time = _iso(report.stop)
253
+
254
+ if (status := _status_from_report(report)) is not None:
255
+ test_item.test_status = status
256
+
257
+ if report.when != "teardown":
258
+ return
259
+
260
+ test_item.test_end_time = _iso(report.start)
261
+ if (setup_stop := self._setup_stop.pop(report.nodeid, None)) is not None:
262
+ test_item.test_duration_sec = report.start - setup_stop
263
+ if report.user_properties:
264
+ test_item.test_user_properties = dict(report.user_properties)
265
+
266
+ if self._config.getoption("--result-each-test"):
267
+ logger.debug(f"Test Results: \n{json.dumps(test_item, indent=4, default=default_serialize)}")
268
+
269
+ def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None:
270
+ if session.config.option.collectonly or not self._tests_started:
271
+ return
272
+
273
+ self._finalize_execution_info(config=session.config)
274
+ output_dir: Path = session.config.option.output_dir
275
+ output_dir.mkdir(parents=True, exist_ok=True)
276
+ save_as_json(
277
+ path=output_dir / EXECUTION_RESULTS_FILENAME, data=self.execution_results, default=default_serialize
278
+ )
279
+ save_as_json(path=output_dir / TEST_RESULTS_FILENAME, data=self.test_results, default=default_serialize)
280
+ logger.info(f"Better report: Execution results saved to {output_dir / EXECUTION_RESULTS_FILENAME}")
281
+ logger.info(f"Better report: Test results saved to {output_dir / TEST_RESULTS_FILENAME}")
282
+
283
+ logger.info(f"Test session finished with exit status: {exitstatus}")
284
+ if exitstatus != 0:
285
+ failed_tests = [v for v in self.test_results.values() if v.test_status == ExecutionStatus.FAILED]
286
+ logger.debug(f"Failed tests: {json.dumps(failed_tests, indent=4, default=default_serialize)}")
287
+
288
+ if session.config.getoption("--md-report"):
289
+ res_md = generate_md_report(report=json.loads(json.dumps(self.test_results, default=default_serialize)))
290
+ save_as_markdown(path=output_dir / MD_REPORT_FILENAME, data=res_md)
291
+
292
+ def _finalize_execution_info(self, config: pytest.Config) -> None:
293
+ exec_info: ExecutionData = self.execution_results["execution_info"]
294
+ exec_info.execution_end_time = datetime.now(UTC).isoformat()
295
+ start = datetime.fromisoformat(exec_info.execution_start_time)
296
+ end = datetime.fromisoformat(exec_info.execution_end_time)
297
+ exec_info.execution_duration_sec = (end - start).total_seconds()
298
+
299
+ passing_statuses = list(_PASSING_STATUSES)
300
+ if not config.getoption("--pytest-xfail-strict"):
301
+ passing_statuses.append(ExecutionStatus.XPASS)
302
+ all_passed = all(t.test_status in passing_statuses for t in self.test_results.values())
303
+ exec_info.execution_status = ExecutionStatus.PASSED if all_passed else ExecutionStatus.FAILED
304
+ exec_info.test_list = list(self.test_results.keys())
305
+
306
+
307
+ def _exception_message(call: pytest.CallInfo[None], with_traceback: bool) -> dict[str, Any]:
308
+ excinfo = call.excinfo
309
+ message = str(excinfo.value).split("\nassert", maxsplit=1)[0]
310
+ # A test can raise with a JSON object as its message to record structured data; anything else is plain text.
311
+ try:
312
+ parsed = json.loads(message)
313
+ except json.JSONDecodeError:
314
+ parsed = None
315
+ exception_message: dict[str, Any] = (
316
+ parsed if isinstance(parsed, dict) else {"exception_type": excinfo.typename, "message": message}
317
+ )
318
+
319
+ if with_traceback:
320
+ exception_message["traceback"] = {
321
+ "repr_crash": excinfo.getrepr().reprcrash,
322
+ "traceback": [str(frame.path) for frame in excinfo.traceback],
323
+ }
324
+ return exception_message
File without changes
@@ -0,0 +1,38 @@
1
+ import json
2
+
3
+
4
+ def _format_message(exception_message: dict | None) -> str:
5
+ if not exception_message:
6
+ return "-"
7
+ if "message" in exception_message:
8
+ return str(exception_message["message"])
9
+ # A test raised with a JSON object as its message: show the object itself (without the traceback).
10
+ return json.dumps({key: value for key, value in exception_message.items() if key != "traceback"})
11
+
12
+
13
+ def generate_md_report(report: dict) -> str:
14
+ status_icons = {
15
+ "passed": "✅",
16
+ "failed": "❌",
17
+ "xpassed": "✅",
18
+ "xfailed": "❌",
19
+ "skipped": "⏭️",
20
+ "collected": "📋",
21
+ }
22
+ rows = ["| No. | Test Name | Status | Duration | Message |", "|:---:|-----------|:------:|:--------:|---------|"]
23
+ stats = dict.fromkeys(status_icons, 0)
24
+ for index, test in enumerate(report.values(), start=1):
25
+ status = test["test_status"]
26
+ stats[status] = stats.get(status, 0) + 1 # an unexpected status is counted, not a KeyError
27
+ name = test["test_full_name"]
28
+ icon = status_icons.get(status, status)
29
+ duration_sec = test["test_duration_sec"]
30
+ duration = f"{duration_sec:.2f}s" if duration_sec is not None else "-"
31
+ msg = _format_message(exception_message=test["exception_message"])
32
+ rows.append(f"|{index}| `{name}` | {icon} {status} | {duration} | `{msg}` |")
33
+
34
+ total_summary = f"\n🧪 Total: {len(report)} &nbsp;&nbsp;| &nbsp;&nbsp;" + " &nbsp;&nbsp;| &nbsp;&nbsp;".join(
35
+ f"{icon} {k.capitalize()}: {v}" for k, v in stats.items() for icon in (status_icons.get(k, ""),)
36
+ )
37
+
38
+ return "## ✅ Test Report Summary\n\n" + "\n".join(rows) + f"\n<br> \n\n### Summary: <br> \n{total_summary}"
@@ -0,0 +1,44 @@
1
+ import json
2
+ from collections.abc import Callable
3
+ from pathlib import Path
4
+
5
+ from custom_python_logger import get_logger
6
+ from python_base_toolkit.utils.data_serialization import default_serialize
7
+
8
+ from pytest_better_report.const import LOGGER_NAME
9
+
10
+ logger = get_logger(f"{LOGGER_NAME}")
11
+
12
+
13
+ def get_project_root(marker: str = ".git") -> Path | None:
14
+ path = Path(__file__).resolve()
15
+ for parent in path.parents:
16
+ if (parent / marker).exists():
17
+ return parent
18
+ return None
19
+
20
+
21
+ def serialize_data(obj: object) -> object: # default_serialize
22
+ return default_serialize(obj=obj)
23
+
24
+
25
+ def open_json(path: Path) -> dict:
26
+ with open(path, encoding="utf-8") as json_file:
27
+ return json.load(json_file)
28
+
29
+
30
+ def save_as_json(path: Path, data: dict, default: Callable | None = None) -> None:
31
+ if not path.parent.exists():
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+
34
+ with open(path, "w", encoding="utf-8") as json_file:
35
+ text = json.dumps(data, indent=4, default=default) if default else json.dumps(data, indent=4)
36
+ json_file.write(text)
37
+
38
+
39
+ def save_as_markdown(path: Path, data: str) -> None:
40
+ if not path.parent.exists():
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+
43
+ with open(path, "w", encoding="utf-8") as md_file:
44
+ md_file.write(data)
@@ -0,0 +1,28 @@
1
+ from _pytest.python import Function
2
+
3
+
4
+ def get_test_path_without_parameters(item: Function) -> str:
5
+ """Get the test name without parameters."""
6
+ return item.nodeid.split("[")[0]
7
+
8
+
9
+ def get_test_name_without_parameters(item: Function) -> str:
10
+ """Get the test name without parameters."""
11
+ return item.nodeid.split(".py::")[-1].split("[")[0]
12
+
13
+
14
+ def get_pytest_test_name(item: Function) -> str:
15
+ """Get the test name without parameters."""
16
+ return item.nodeid.split(".py::")[-1]
17
+
18
+
19
+ def get_test_full_name(item: Function) -> str:
20
+ """Get the full name of the test, including parameters if available."""
21
+ test_name = get_test_name_without_parameters(item=item)
22
+ return f"{test_name}[{item.callspec.params}]" if getattr(item, "callspec", None) else test_name
23
+
24
+
25
+ def get_test_full_path(item: Function) -> str:
26
+ """Get the full name of the test, including parameters if available."""
27
+ test_name = get_test_path_without_parameters(item=item)
28
+ return f"{test_name}[{item.callspec.params}]" if getattr(item, "callspec", None) else test_name
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-better-report
3
+ Version: 1.0.0
4
+ Summary: A pytest plugin that writes structured JSON (and optional Markdown) reports of every run, with CI metadata and a CLI to compare two runs.
5
+ Author: Avi Zaguri
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/aviz92/pytest-better-report
8
+ Project-URL: Repository, https://github.com/aviz92/pytest-better-report
9
+ Project-URL: Issues, https://github.com/aviz92/pytest-better-report/issues
10
+ Keywords: pytest,plugin,testing,report,json,markdown,ci
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Framework :: Pytest
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Software Development :: Testing
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: custom-python-logger>=4.1.0
21
+ Requires-Dist: pytest>=9.2.0
22
+ Requires-Dist: python-base-command>=0.1.8
23
+ Requires-Dist: python-base-toolkit>=1.1.2
24
+ Dynamic: license-file
25
+
26
+ ![PyPI version](https://img.shields.io/pypi/v/pytest-better-report)
27
+ ![Python](https://img.shields.io/badge/python->=3.12-blue)
28
+ ![Development Status](https://img.shields.io/badge/status-stable-green)
29
+ ![Maintenance](https://img.shields.io/maintenance/yes/2026)
30
+ ![PyPI](https://img.shields.io/pypi/dm/pytest-better-report)
31
+ ![License](https://img.shields.io/pypi/l/pytest-better-report)
32
+
33
+ ---
34
+
35
+ # 💡 pytest-better-report
36
+ A pytest plugin that writes a structured report of every run: a JSON file per test, a JSON file for the run with CI
37
+ metadata (repo, PR/MR, pipeline, commit), and an optional Markdown summary. Includes a CLI to compare two runs.
38
+
39
+ ---
40
+
41
+ ## 📦 Installation
42
+ ```bash
43
+ uv add pytest-better-report
44
+ ```
45
+
46
+ ---
47
+
48
+ ## 🚀 Features
49
+ - ✅ **Structured JSON** — `test_results.json` (one entry per test) and `execution_results.json` (the run)
50
+ - ✅ **CI traceability** — repo name, PR / MR number, pipeline number and commit in the run report
51
+ - ✅ **Markdown summary** — a table of every test with status, duration and message
52
+ - ✅ **Structured failure data** — raise with a JSON object as the message to record it as data
53
+ - ✅ **Compare two runs** — `better_report_compare` lists missing tests and status differences
54
+ - ✅ **Plays well with other plugins** — statuses come from pytest's final reports, whatever the plugin order
55
+ - ✅ **Zero footprint without the flag** — nothing is registered, no hook runs
56
+
57
+ ---
58
+
59
+ ## 🛠️ How to Use
60
+ ```bash
61
+ pytest --better-report --md-report --pr-number=123 --commit="$GIT_SHA"
62
+ ```
63
+ Files are written to `results_output/` (or `<output-dir>/results_output/`):
64
+
65
+ | File | Content |
66
+ |---|---|
67
+ | `test_results.json` | One entry per test: name, file, class, parameters, markers, status, times, exception, user properties |
68
+ | `execution_results.json` | The run: status, start / end / duration, CI metadata, environment, optionally the pytest command |
69
+ | `test_report.md` | With `--md-report`: a table of all tests and a summary by status |
70
+
71
+ ---
72
+
73
+ ## ⚙️ Flags
74
+ | Flag | Description |
75
+ |---|---|
76
+ | `--better-report` | Enable the report |
77
+ | `--output-dir=DIR` | Write the files to `DIR/results_output/` |
78
+ | `--md-report` | Also write `test_report.md` |
79
+ | `--traceback` | Add the crash location and traceback frames to each exception |
80
+ | `--repo-name`, `--pr-number`, `--mr-number`, `--pipeline-number`, `--commit` | CI metadata for `execution_results.json` |
81
+ | `--add-parameters` | Add each test parameter as its own field in `test_results.json` |
82
+ | `--pytest-command` | Record the pytest command line in `execution_results.json` |
83
+ | `--pytest-xfail-strict` | Treat an unexpected pass (XPASS) as a failure of the run |
84
+ | `--result-each-test` | Log each test's result after it runs |
85
+ | `--log-collected-tests` | Log every collected test at the start (works without `--better-report` too) |
86
+
87
+ ---
88
+
89
+ ## 📊 Statuses
90
+ Each test gets one status in `test_results.json`.
91
+ The run as a whole gets `execution_info.execution_status` in `execution_results.json`: `failed` if **at least one**
92
+ test has a status marked "Yes" in the last column, otherwise `passed`.
93
+
94
+ This is the report's own verdict on the run, written into the file. It is separate from pytest's exit code, which this
95
+ plugin doesn't change.
96
+
97
+ | Test status | Meaning | Makes `execution_status` = `failed` |
98
+ |---|---|---|
99
+ | `passed` | Passed | No |
100
+ | `failed` | Failed or errored in setup, call or teardown | Yes |
101
+ | `skipped` | Skipped (marker, fixture, or a plugin that converted the outcome) | No |
102
+ | `xfailed` | Expected failure | No |
103
+ | `xpassed` | Unexpected pass | Only with `--pytest-xfail-strict` |
104
+ | `collected` | Collected but not run (e.g. the run stopped early) | No |
105
+
106
+ ---
107
+
108
+ ## ▶️ Usage Examples
109
+ ### Record structured failure data
110
+ ```python
111
+ import json
112
+
113
+ def test_api():
114
+ response = call_api()
115
+ if response.status != 200:
116
+ raise AssertionError(json.dumps({"status": response.status, "body": response.text}))
117
+ ```
118
+ The exception is recorded as `{"status": ..., "body": ...}` instead of plain text.
119
+
120
+ ### Compare two runs
121
+ ```bash
122
+ better_report_compare -a main/test_results.json -b branch/test_results.json --fail-on-diff
123
+ ```
124
+ Lists tests missing from either side and tests whose status changed; exits non-zero with `--fail-on-diff` when
125
+ there are differences. `--test-file-name` limits the comparison to one test file.
126
+
127
+ ---
128
+
129
+ ## 🔁 Migrating from pytest-plugins
130
+ This plugin was previously `better_report` in `pytest-plugins`. Flags, file names and the JSON schema are unchanged.
131
+ Both register the same options, so don't install them together until you're on a `pytest-plugins` release without it.
132
+ Differences you may notice:
133
+ - A test that errors in teardown is now `failed` (it was reported as passed).
134
+ - The `failed-skipped` status no longer exists; converted failures are `skipped`, with the reason in `test_user_properties`.
135
+ - The logger is named `pytest_better_report`.
136
+
137
+ ---
138
+
139
+ ## 🤝 Contributing
140
+ 1. Fork the repository
141
+ 2. Create a feature branch: `git checkout -b feature/my-addition`
142
+ 3. Make your changes
143
+ 4. Commit your changes: `git commit -m 'Add my addition'`
144
+ 5. Push to the branch: `git push origin feature/my-addition`
145
+ 6. Open a Pull Request
146
+
147
+ Additions that promote clean, productive, and maintainable development are always welcome.
148
+
149
+ ---
150
+
151
+ ## 📄 License
152
+ MIT License — see [LICENSE](LICENSE) for details.
153
+
154
+ ---
155
+
156
+ ## 🙏 Thanks
157
+ Thanks for exploring this repository! <br>
158
+ Happy coding!
159
+
160
+ [![GitHub](https://img.shields.io/badge/GitHub-aviz92-181717?logo=github)](https://github.com/aviz92)
161
+ &nbsp; [![PyPI](https://img.shields.io/badge/PyPI-aviz-3775A9?logo=pypi)](https://pypi.org/user/aviz/)
162
+ &nbsp; [![Blog](https://img.shields.io/badge/Blog-aviz92.github.io-0066CC?logo=googlechrome)](https://aviz92.github.io/)
163
+ &nbsp; [![LinkedIn](https://img.shields.io/badge/LinkedIn-avi--zaguri-0A66C2?logo=linkedin)](https://www.linkedin.com/in/avi-zaguri-41869b11b)
@@ -0,0 +1,19 @@
1
+ pytest_better_report/__init__.py,sha256=6CjdXPlwznDcEFpfDMEH7KZGveNvziuEb5MRSMziplA,184
2
+ pytest_better_report/compare.py,sha256=pj4ynKT0zsRaJONueY81MiummdoJoLFR7WaNYwmz7q4,6003
3
+ pytest_better_report/const.py,sha256=Uv8geS601qFxIMiiJvo5_2QVibG_NmUQVjtXQ75S26k,258
4
+ pytest_better_report/plugin.py,sha256=XoXO9O9hJvPr_T0SPnEm64C9wR8wiEMLz3cywXsKfgw,14170
5
+ pytest_better_report/models/__init__.py,sha256=X6GwVDzXndlui_5sDNfwnkty8saeltmf-hy3GYr_fVw,251
6
+ pytest_better_report/models/environment_data.py,sha256=OMezmiFttnpwQKY53YSlfdRXncChO7jzUhXQVWEsPz4,126
7
+ pytest_better_report/models/execution_data.py,sha256=IyTGdNg1ahehNyO8CoraDRxutUqQkGfJqXRFcoMYSMU,552
8
+ pytest_better_report/models/status.py,sha256=RXKkfy4zJQufOlQZjcbJLbxDwVEQ9KDr8CepVU0Dh6Y,275
9
+ pytest_better_report/models/test_data.py,sha256=xJriKroMEy4kgVr2ywtbzCxN0GLtNybnxY-Rt8mOe6Y,827
10
+ pytest_better_report/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ pytest_better_report/utils/create_report.py,sha256=JDlL5I2T3JxTHaIalUwKq2WbTQ9ur36vVNUFX9vVqgc,1726
12
+ pytest_better_report/utils/helper.py,sha256=YI4E0fHZFzPZy-Mf4-gUKfBS4KczUzP2JNXr5iUGbjc,1314
13
+ pytest_better_report/utils/pytest_helper.py,sha256=Nkzz8p8277GgYpv47TJeN1381WXoF24Mph0Uf-937_g,1062
14
+ pytest_better_report-1.0.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
15
+ pytest_better_report-1.0.0.dist-info/METADATA,sha256=s-n0txzFp0k7FGxJwiUJ-WUhIk2D76AZKjTtvC-u4NI,6963
16
+ pytest_better_report-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
17
+ pytest_better_report-1.0.0.dist-info/entry_points.txt,sha256=CSkW_An0_xj_uVsqD8XYDc-4h-1VgHuW38nRqadqdDw,132
18
+ pytest_better_report-1.0.0.dist-info/top_level.txt,sha256=Q6-6WF0ZWDa2MrrIs7gcl15WBMl6GXspjFb36n3ipBY,21
19
+ pytest_better_report-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ better_report_compare = pytest_better_report.compare:main
3
+
4
+ [pytest11]
5
+ better_report = pytest_better_report.plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Avi Zaguri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pytest_better_report