pytest-why 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pytest-why contributors
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
+ include tests/conftest.py
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-why
3
+ Version: 0.1.0
4
+ Summary: A pytest plugin that explains failing tests like a senior engineer.
5
+ Author: pytest-why contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://www.dhirajdas.dev
8
+ Project-URL: Repository, https://github.com/godhiraj-code/pytest-why
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: Pytest
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: pytest>=7
24
+ Provides-Extra: dev
25
+ Requires-Dist: build; extra == "dev"
26
+ Requires-Dist: pytest>=7; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # pytest-why
30
+
31
+ > A pytest plugin that explains failing tests like a senior engineer.
32
+
33
+ `pytest-why` turns common pytest failures into concise explanations and practical
34
+ next steps. It runs locally, adds no noise to normal test runs, and creates
35
+ shareable Markdown and HTML reports when you ask for them.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ python -m pip install pytest-why
41
+ ```
42
+
43
+ For local development:
44
+
45
+ ```bash
46
+ python -m pip install -e .[dev]
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ Run pytest with one extra flag:
52
+
53
+ ```bash
54
+ python -m pytest --why
55
+ ```
56
+
57
+ Without `--why`, the plugin does nothing and creates no report files.
58
+
59
+ ## Viral demo
60
+
61
+ Given:
62
+
63
+ ```python
64
+ def test_math():
65
+ assert 2 + 2 == 5
66
+ ```
67
+
68
+ Run:
69
+
70
+ ```bash
71
+ pytest --why
72
+ ```
73
+
74
+ Sample output:
75
+
76
+ ```text
77
+ ================ pytest-why: failure explanations ================
78
+ Total failures: 1
79
+ Assertion mismatch: test_math.py::test_math (call)
80
+ Why: The code ran, but the observed value or state did not match what the test expected.
81
+ Hint: Compare the expected and actual values near the final assertion, then trace where they first diverge.
82
+ Reports: pytest-why-report.md, pytest-why-report.html
83
+ ```
84
+
85
+ ## Reports
86
+
87
+ Each `--why` run writes:
88
+
89
+ - `pytest-why-report.md` for pull requests, issue trackers, and terminals
90
+ - `pytest-why-report.html` for a styled, standalone browser view
91
+
92
+ Both reports include the failing test, pytest phase, classification, duration,
93
+ explanation, hint, and the complete raw traceback.
94
+
95
+ ## Supported classifications
96
+
97
+ - **Assertion mismatch**: expected and actual values differ
98
+ - **Import error**: a module or symbol could not be imported
99
+ - **Fixture error**: missing fixtures, scope mismatches, or recursive dependencies
100
+ - **Timeout**: a test or operation exceeded its time limit
101
+ - **Unknown failure**: deterministic fallback with traceback-first guidance
102
+
103
+ Selenium and Playwright tracebacks also receive a focused browser automation
104
+ hint covering selectors, waits, page timing, and element visibility.
105
+
106
+ The MVP is deterministic and local. No LLM or network connection is required.
107
+
108
+ **Stop doomscrolling tracebacks. Run pytest --why.**
@@ -0,0 +1,80 @@
1
+ # pytest-why
2
+
3
+ > A pytest plugin that explains failing tests like a senior engineer.
4
+
5
+ `pytest-why` turns common pytest failures into concise explanations and practical
6
+ next steps. It runs locally, adds no noise to normal test runs, and creates
7
+ shareable Markdown and HTML reports when you ask for them.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ python -m pip install pytest-why
13
+ ```
14
+
15
+ For local development:
16
+
17
+ ```bash
18
+ python -m pip install -e .[dev]
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Run pytest with one extra flag:
24
+
25
+ ```bash
26
+ python -m pytest --why
27
+ ```
28
+
29
+ Without `--why`, the plugin does nothing and creates no report files.
30
+
31
+ ## Viral demo
32
+
33
+ Given:
34
+
35
+ ```python
36
+ def test_math():
37
+ assert 2 + 2 == 5
38
+ ```
39
+
40
+ Run:
41
+
42
+ ```bash
43
+ pytest --why
44
+ ```
45
+
46
+ Sample output:
47
+
48
+ ```text
49
+ ================ pytest-why: failure explanations ================
50
+ Total failures: 1
51
+ Assertion mismatch: test_math.py::test_math (call)
52
+ Why: The code ran, but the observed value or state did not match what the test expected.
53
+ Hint: Compare the expected and actual values near the final assertion, then trace where they first diverge.
54
+ Reports: pytest-why-report.md, pytest-why-report.html
55
+ ```
56
+
57
+ ## Reports
58
+
59
+ Each `--why` run writes:
60
+
61
+ - `pytest-why-report.md` for pull requests, issue trackers, and terminals
62
+ - `pytest-why-report.html` for a styled, standalone browser view
63
+
64
+ Both reports include the failing test, pytest phase, classification, duration,
65
+ explanation, hint, and the complete raw traceback.
66
+
67
+ ## Supported classifications
68
+
69
+ - **Assertion mismatch**: expected and actual values differ
70
+ - **Import error**: a module or symbol could not be imported
71
+ - **Fixture error**: missing fixtures, scope mismatches, or recursive dependencies
72
+ - **Timeout**: a test or operation exceeded its time limit
73
+ - **Unknown failure**: deterministic fallback with traceback-first guidance
74
+
75
+ Selenium and Playwright tracebacks also receive a focused browser automation
76
+ hint covering selectors, waits, page timing, and element visibility.
77
+
78
+ The MVP is deterministic and local. No LLM or network connection is required.
79
+
80
+ **Stop doomscrolling tracebacks. Run pytest --why.**
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pytest-why"
7
+ version = "0.1.0"
8
+ description = "A pytest plugin that explains failing tests like a senior engineer."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "pytest-why contributors" }
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Framework :: Pytest",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Testing",
27
+ ]
28
+ dependencies = [
29
+ "pytest>=7",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://www.dhirajdas.dev"
34
+ Repository = "https://github.com/godhiraj-code/pytest-why"
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "build",
39
+ "pytest>=7",
40
+ ]
41
+
42
+ [project.entry-points.pytest11]
43
+ why = "pytest_why.plugin"
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
47
+
48
+ [tool.pytest.ini_options]
49
+ addopts = "-ra"
50
+ testpaths = ["tests"]
51
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """pytest-why: concise, deterministic explanations for pytest failures."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,130 @@
1
+ """Deterministic pytest failure classification."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+
6
+ BROWSER_AUTOMATION_HINT = (
7
+ "Browser automation is involved. Verify selectors, waits, page load timing, "
8
+ "and whether the element is visible before interaction."
9
+ )
10
+
11
+
12
+ def _contains_any(text: str, needles: tuple[str, ...]) -> bool:
13
+ lowered = text.lower()
14
+ return any(needle.lower() in lowered for needle in needles)
15
+
16
+
17
+ def _with_browser_hint(hint: str, longreprtext: str) -> str:
18
+ browser_terms = (
19
+ "selenium",
20
+ "webdriver",
21
+ "NoSuchElementException",
22
+ "StaleElementReferenceException",
23
+ "playwright",
24
+ "locator",
25
+ "page.",
26
+ )
27
+ if _contains_any(longreprtext, browser_terms):
28
+ return f"{hint} {BROWSER_AUTOMATION_HINT}"
29
+ return hint
30
+
31
+
32
+ def classify_failure(
33
+ nodeid: str,
34
+ phase: str,
35
+ longreprtext: str,
36
+ duration: Optional[float] = None,
37
+ ) -> Dict[str, Any]:
38
+ """Classify a pytest failure and return its explanation fields."""
39
+ text = longreprtext or ""
40
+
41
+ is_fixture_failure = phase == "setup" and (
42
+ "ScopeMismatch" in text
43
+ or "recursive dependency involving fixture" in text
44
+ or "recursive fixture dependency" in text
45
+ or ("fixture" in text.lower() and "not found" in text.lower())
46
+ )
47
+
48
+ if is_fixture_failure:
49
+ result = {
50
+ "type": "fixture_error",
51
+ "title": "Fixture error",
52
+ "explanation": (
53
+ "Pytest could not prepare the test because a fixture is missing, "
54
+ "has an incompatible scope, or depends on itself."
55
+ ),
56
+ "hint": (
57
+ "Check the fixture name, where it is defined, its scope, and its "
58
+ "dependency chain."
59
+ ),
60
+ }
61
+ elif _contains_any(
62
+ text,
63
+ ("ImportError", "ModuleNotFoundError", "cannot import name", "No module named"),
64
+ ):
65
+ result = {
66
+ "type": "import_error",
67
+ "title": "Import error",
68
+ "explanation": (
69
+ "Python could not import a module or symbol required while collecting "
70
+ "or running this test."
71
+ ),
72
+ "hint": (
73
+ "Verify the package is installed, the import path is correct, and the "
74
+ "symbol exists without a circular import."
75
+ ),
76
+ }
77
+ elif _contains_any(
78
+ text,
79
+ ("Timeout", "TimeoutError", "timed out", "pytest-timeout", "Failed: Timeout"),
80
+ ):
81
+ result = {
82
+ "type": "timeout",
83
+ "title": "Timeout",
84
+ "explanation": (
85
+ "The test exceeded a time limit or waited too long for an operation "
86
+ "to complete."
87
+ ),
88
+ "hint": (
89
+ "Find the blocked operation, replace fixed sleeps with bounded waits, "
90
+ "and confirm external dependencies respond."
91
+ ),
92
+ }
93
+ elif _contains_any(
94
+ text,
95
+ (
96
+ "AssertionError",
97
+ "E assert",
98
+ "Differing items",
99
+ "Left contains",
100
+ "Right contains",
101
+ ),
102
+ ):
103
+ result = {
104
+ "type": "assertion_mismatch",
105
+ "title": "Assertion mismatch",
106
+ "explanation": (
107
+ "The code ran, but the observed value or state did not match what the "
108
+ "test expected."
109
+ ),
110
+ "hint": (
111
+ "Compare the expected and actual values near the final assertion, then "
112
+ "trace where they first diverge."
113
+ ),
114
+ }
115
+ else:
116
+ result = {
117
+ "type": "unknown_failure",
118
+ "title": "Unknown failure",
119
+ "explanation": (
120
+ "The failure does not match a known pytest-why pattern, so the "
121
+ "traceback remains the best source of detail."
122
+ ),
123
+ "hint": (
124
+ "Start at the last application frame in the traceback and inspect the "
125
+ "inputs and state immediately before the exception."
126
+ ),
127
+ }
128
+
129
+ result["hint"] = _with_browser_hint(result["hint"], text)
130
+ return result
@@ -0,0 +1,76 @@
1
+ """Pytest hooks for pytest-why."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List
5
+
6
+ import pytest
7
+
8
+ from .classifier import classify_failure
9
+ from .reporter import write_html_report, write_markdown_report
10
+
11
+
12
+ def pytest_addoption(parser: pytest.Parser) -> None:
13
+ group = parser.getgroup("pytest-why")
14
+ group.addoption(
15
+ "--why",
16
+ action="store_true",
17
+ default=False,
18
+ help="Explain failures and write pytest-why Markdown and HTML reports.",
19
+ )
20
+
21
+
22
+ def pytest_configure(config: pytest.Config) -> None:
23
+ if config.getoption("--why"):
24
+ config.pluginmanager.register(WhyPlugin(), "pytest-why-runtime")
25
+
26
+
27
+ class WhyPlugin:
28
+ def __init__(self) -> None:
29
+ self.failures: List[Dict[str, Any]] = []
30
+
31
+ def _record_failure(self, report: Any, phase: str) -> None:
32
+ if not report.failed:
33
+ return
34
+
35
+ duration = getattr(report, "duration", None)
36
+ classification = classify_failure(
37
+ nodeid=report.nodeid,
38
+ phase=phase,
39
+ longreprtext=report.longreprtext,
40
+ duration=duration,
41
+ )
42
+ self.failures.append(
43
+ {
44
+ "nodeid": report.nodeid,
45
+ "phase": phase,
46
+ "duration": duration,
47
+ "longreprtext": report.longreprtext,
48
+ **classification,
49
+ }
50
+ )
51
+
52
+ def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
53
+ self._record_failure(report, report.when)
54
+
55
+ def pytest_collectreport(self, report: pytest.CollectReport) -> None:
56
+ self._record_failure(report, "collect")
57
+
58
+ def pytest_terminal_summary(
59
+ self,
60
+ terminalreporter: pytest.TerminalReporter,
61
+ exitstatus: int,
62
+ config: pytest.Config,
63
+ ) -> None:
64
+ terminalreporter.section("pytest-why: failure explanations")
65
+ terminalreporter.write_line(f"Total failures: {len(self.failures)}")
66
+ for failure in self.failures:
67
+ terminalreporter.write_line(
68
+ f"{failure['title']}: {failure['nodeid']} ({failure['phase']})"
69
+ )
70
+ terminalreporter.write_line(f" Why: {failure['explanation']}")
71
+ terminalreporter.write_line(f" Hint: {failure['hint']}")
72
+
73
+ report_dir = Path.cwd()
74
+ write_markdown_report(self.failures, report_dir / "pytest-why-report.md")
75
+ write_html_report(self.failures, report_dir / "pytest-why-report.html")
76
+ terminalreporter.write_line("Reports: pytest-why-report.md, pytest-why-report.html")
@@ -0,0 +1,147 @@
1
+ """Markdown and HTML report writers."""
2
+
3
+ from html import escape
4
+ from pathlib import Path
5
+ from typing import Any, Iterable, Mapping, Union
6
+
7
+
8
+ Failure = Mapping[str, Any]
9
+ PathLike = Union[str, Path]
10
+
11
+
12
+ def _duration(failure: Failure) -> str:
13
+ duration = failure.get("duration")
14
+ if duration is None:
15
+ return "n/a"
16
+ return f"{float(duration):.3f}s"
17
+
18
+
19
+ def _backtick_fence(content: str) -> str:
20
+ longest_run = 0
21
+ current_run = 0
22
+ for character in content:
23
+ if character == "`":
24
+ current_run += 1
25
+ longest_run = max(longest_run, current_run)
26
+ else:
27
+ current_run = 0
28
+ return "`" * max(3, longest_run + 1)
29
+
30
+
31
+ def write_markdown_report(
32
+ failures: Iterable[Failure],
33
+ path: PathLike = "pytest-why-report.md",
34
+ ) -> None:
35
+ """Write a human-readable Markdown failure report."""
36
+ items = list(failures)
37
+ lines = [
38
+ "# pytest-why failure explanations",
39
+ "",
40
+ f"**Total failures:** {len(items)}",
41
+ "",
42
+ ]
43
+
44
+ for index, failure in enumerate(items, start=1):
45
+ traceback = str(failure.get("longreprtext", "")).rstrip()
46
+ fence = _backtick_fence(traceback)
47
+ lines.extend(
48
+ [
49
+ f"## {index}. `{failure['nodeid']}`",
50
+ "",
51
+ f"- **Phase:** `{failure['phase']}`",
52
+ f"- **Type:** `{failure['type']}` - {failure['title']}",
53
+ f"- **Duration:** {_duration(failure)}",
54
+ "",
55
+ f"**Why:** {failure['explanation']}",
56
+ "",
57
+ f"**Hint:** {failure['hint']}",
58
+ "",
59
+ "<details>",
60
+ "<summary>Raw traceback</summary>",
61
+ "",
62
+ f"{fence}text",
63
+ traceback,
64
+ fence,
65
+ "",
66
+ "</details>",
67
+ "",
68
+ ]
69
+ )
70
+
71
+ Path(path).write_text("\n".join(lines), encoding="utf-8")
72
+
73
+
74
+ def write_html_report(
75
+ failures: Iterable[Failure],
76
+ path: PathLike = "pytest-why-report.html",
77
+ ) -> None:
78
+ """Write a standalone HTML failure report."""
79
+ items = list(failures)
80
+ cards = []
81
+ for failure in items:
82
+ cards.append(
83
+ """
84
+ <article class="card">
85
+ <h2>{nodeid}</h2>
86
+ <div class="meta">
87
+ <span>Phase: <code>{phase}</code></span>
88
+ <span>Type: <code>{type}</code> - {title}</span>
89
+ <span>Duration: {duration}</span>
90
+ </div>
91
+ <h3>Why</h3>
92
+ <p>{explanation}</p>
93
+ <h3>Hint</h3>
94
+ <p>{hint}</p>
95
+ <details>
96
+ <summary>Raw traceback</summary>
97
+ <pre>{traceback}</pre>
98
+ </details>
99
+ </article>
100
+ """.format(
101
+ nodeid=escape(str(failure["nodeid"])),
102
+ phase=escape(str(failure["phase"])),
103
+ type=escape(str(failure["type"])),
104
+ title=escape(str(failure["title"])),
105
+ duration=escape(_duration(failure)),
106
+ explanation=escape(str(failure["explanation"])),
107
+ hint=escape(str(failure["hint"])),
108
+ traceback=escape(str(failure.get("longreprtext", ""))),
109
+ )
110
+ )
111
+
112
+ document = """<!doctype html>
113
+ <html lang="en">
114
+ <head>
115
+ <meta charset="utf-8">
116
+ <meta name="viewport" content="width=device-width, initial-scale=1">
117
+ <title>pytest-why failure explanations</title>
118
+ <style>
119
+ :root {{ color-scheme: light dark; }}
120
+ body {{ font-family: system-ui, sans-serif; max-width: 960px; margin: 0 auto;
121
+ padding: 2rem; line-height: 1.5; background: #f6f7f9; color: #18202a; }}
122
+ header {{ margin-bottom: 1.5rem; }}
123
+ .card {{ background: white; border: 1px solid #d9dee5; border-radius: 10px;
124
+ padding: 1.25rem; margin: 1rem 0; box-shadow: 0 2px 8px #0000000d; }}
125
+ .card h2 {{ overflow-wrap: anywhere; margin-top: 0; }}
126
+ .meta {{ display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; color: #4b5563; }}
127
+ code, pre {{ font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }}
128
+ pre {{ overflow: auto; padding: 1rem; border-radius: 6px; background: #111827;
129
+ color: #e5e7eb; white-space: pre-wrap; }}
130
+ summary {{ cursor: pointer; font-weight: 600; }}
131
+ @media (prefers-color-scheme: dark) {{
132
+ body {{ background: #111827; color: #e5e7eb; }}
133
+ .card {{ background: #1f2937; border-color: #374151; }}
134
+ .meta {{ color: #cbd5e1; }}
135
+ }}
136
+ </style>
137
+ </head>
138
+ <body>
139
+ <header>
140
+ <h1>pytest-why: failure explanations</h1>
141
+ <p><strong>Total failures:</strong> {count}</p>
142
+ </header>
143
+ <main>{cards}</main>
144
+ </body>
145
+ </html>
146
+ """.format(count=len(items), cards="".join(cards))
147
+ Path(path).write_text(document, encoding="utf-8")
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-why
3
+ Version: 0.1.0
4
+ Summary: A pytest plugin that explains failing tests like a senior engineer.
5
+ Author: pytest-why contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://www.dhirajdas.dev
8
+ Project-URL: Repository, https://github.com/godhiraj-code/pytest-why
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: Pytest
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: pytest>=7
24
+ Provides-Extra: dev
25
+ Requires-Dist: build; extra == "dev"
26
+ Requires-Dist: pytest>=7; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # pytest-why
30
+
31
+ > A pytest plugin that explains failing tests like a senior engineer.
32
+
33
+ `pytest-why` turns common pytest failures into concise explanations and practical
34
+ next steps. It runs locally, adds no noise to normal test runs, and creates
35
+ shareable Markdown and HTML reports when you ask for them.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ python -m pip install pytest-why
41
+ ```
42
+
43
+ For local development:
44
+
45
+ ```bash
46
+ python -m pip install -e .[dev]
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ Run pytest with one extra flag:
52
+
53
+ ```bash
54
+ python -m pytest --why
55
+ ```
56
+
57
+ Without `--why`, the plugin does nothing and creates no report files.
58
+
59
+ ## Viral demo
60
+
61
+ Given:
62
+
63
+ ```python
64
+ def test_math():
65
+ assert 2 + 2 == 5
66
+ ```
67
+
68
+ Run:
69
+
70
+ ```bash
71
+ pytest --why
72
+ ```
73
+
74
+ Sample output:
75
+
76
+ ```text
77
+ ================ pytest-why: failure explanations ================
78
+ Total failures: 1
79
+ Assertion mismatch: test_math.py::test_math (call)
80
+ Why: The code ran, but the observed value or state did not match what the test expected.
81
+ Hint: Compare the expected and actual values near the final assertion, then trace where they first diverge.
82
+ Reports: pytest-why-report.md, pytest-why-report.html
83
+ ```
84
+
85
+ ## Reports
86
+
87
+ Each `--why` run writes:
88
+
89
+ - `pytest-why-report.md` for pull requests, issue trackers, and terminals
90
+ - `pytest-why-report.html` for a styled, standalone browser view
91
+
92
+ Both reports include the failing test, pytest phase, classification, duration,
93
+ explanation, hint, and the complete raw traceback.
94
+
95
+ ## Supported classifications
96
+
97
+ - **Assertion mismatch**: expected and actual values differ
98
+ - **Import error**: a module or symbol could not be imported
99
+ - **Fixture error**: missing fixtures, scope mismatches, or recursive dependencies
100
+ - **Timeout**: a test or operation exceeded its time limit
101
+ - **Unknown failure**: deterministic fallback with traceback-first guidance
102
+
103
+ Selenium and Playwright tracebacks also receive a focused browser automation
104
+ hint covering selectors, waits, page timing, and element visibility.
105
+
106
+ The MVP is deterministic and local. No LLM or network connection is required.
107
+
108
+ **Stop doomscrolling tracebacks. Run pytest --why.**
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ src/pytest_why/__init__.py
6
+ src/pytest_why/classifier.py
7
+ src/pytest_why/plugin.py
8
+ src/pytest_why/reporter.py
9
+ src/pytest_why.egg-info/PKG-INFO
10
+ src/pytest_why.egg-info/SOURCES.txt
11
+ src/pytest_why.egg-info/dependency_links.txt
12
+ src/pytest_why.egg-info/entry_points.txt
13
+ src/pytest_why.egg-info/requires.txt
14
+ src/pytest_why.egg-info/top_level.txt
15
+ tests/conftest.py
16
+ tests/test_classifier.py
17
+ tests/test_plugin.py
18
+ tests/test_reporter.py
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ why = pytest_why.plugin
@@ -0,0 +1,5 @@
1
+ pytest>=7
2
+
3
+ [dev]
4
+ build
5
+ pytest>=7
@@ -0,0 +1 @@
1
+ pytest_why
@@ -0,0 +1 @@
1
+ pytest_plugins = ["pytester"]
@@ -0,0 +1,67 @@
1
+ import pytest
2
+
3
+ from pytest_why.classifier import BROWSER_AUTOMATION_HINT, classify_failure
4
+
5
+
6
+ @pytest.mark.parametrize(
7
+ ("phase", "traceback", "expected_type", "expected_title"),
8
+ [
9
+ (
10
+ "call",
11
+ "E assert {'a': 1} == {'a': 2}\nDiffering items:",
12
+ "assertion_mismatch",
13
+ "Assertion mismatch",
14
+ ),
15
+ (
16
+ "setup",
17
+ "ModuleNotFoundError: No module named 'missing_package'",
18
+ "import_error",
19
+ "Import error",
20
+ ),
21
+ (
22
+ "setup",
23
+ "fixture 'database' not found",
24
+ "fixture_error",
25
+ "Fixture error",
26
+ ),
27
+ (
28
+ "call",
29
+ "Failed: Timeout (>2.0s) from pytest-timeout",
30
+ "timeout",
31
+ "Timeout",
32
+ ),
33
+ (
34
+ "teardown",
35
+ "RuntimeError: cleanup broke",
36
+ "unknown_failure",
37
+ "Unknown failure",
38
+ ),
39
+ ],
40
+ )
41
+ def test_classify_failure(phase, traceback, expected_type, expected_title):
42
+ result = classify_failure("tests/test_example.py::test_case", phase, traceback, 0.1)
43
+
44
+ assert result["type"] == expected_type
45
+ assert result["title"] == expected_title
46
+ assert result["explanation"]
47
+ assert result["hint"]
48
+
49
+
50
+ def test_fixture_terms_only_classify_as_fixture_error_during_setup():
51
+ result = classify_failure(
52
+ "tests/test_example.py::test_case",
53
+ "call",
54
+ "AssertionError: fixture value was not found",
55
+ )
56
+
57
+ assert result["type"] == "assertion_mismatch"
58
+
59
+
60
+ def test_browser_automation_hint_is_added():
61
+ result = classify_failure(
62
+ "tests/test_ui.py::test_login",
63
+ "call",
64
+ "selenium.common.exceptions.NoSuchElementException: missing button",
65
+ )
66
+
67
+ assert BROWSER_AUTOMATION_HINT in result["hint"]
@@ -0,0 +1,84 @@
1
+ def test_why_explains_assertion_and_writes_reports(pytester):
2
+ pytester.makepyfile(
3
+ test_demo="""
4
+ def test_math():
5
+ assert 2 + 2 == 5
6
+ """
7
+ )
8
+
9
+ result = pytester.runpytest("--why")
10
+
11
+ result.assert_outcomes(failed=1)
12
+ result.stdout.fnmatch_lines(
13
+ [
14
+ "*pytest-why: failure explanations*",
15
+ "Total failures: 1",
16
+ "Assertion mismatch: test_demo.py::test_math (call)",
17
+ "*Reports: pytest-why-report.md, pytest-why-report.html*",
18
+ ]
19
+ )
20
+ assert (pytester.path / "pytest-why-report.md").is_file()
21
+ assert (pytester.path / "pytest-why-report.html").is_file()
22
+
23
+
24
+ def test_without_why_has_no_summary_or_reports(pytester):
25
+ pytester.makepyfile(
26
+ test_demo="""
27
+ def test_math():
28
+ assert 2 + 2 == 5
29
+ """
30
+ )
31
+
32
+ result = pytester.runpytest()
33
+
34
+ result.assert_outcomes(failed=1)
35
+ assert "pytest-why: failure explanations" not in result.stdout.str()
36
+ assert not (pytester.path / "pytest-why-report.md").exists()
37
+ assert not (pytester.path / "pytest-why-report.html").exists()
38
+
39
+
40
+ def test_fixture_failure_is_classified_during_setup(pytester):
41
+ pytester.makepyfile(
42
+ test_fixture="""
43
+ def test_needs_fixture(missing_fixture):
44
+ pass
45
+ """
46
+ )
47
+
48
+ result = pytester.runpytest("--why")
49
+
50
+ result.assert_outcomes(errors=1)
51
+ result.stdout.fnmatch_lines(
52
+ ["Fixture error: test_fixture.py::test_needs_fixture (setup)"]
53
+ )
54
+ markdown = (pytester.path / "pytest-why-report.md").read_text(encoding="utf-8")
55
+ assert "`fixture_error` - Fixture error" in markdown
56
+
57
+
58
+ def test_collection_import_error_is_explained_and_writes_reports(pytester):
59
+ pytester.makepyfile(
60
+ test_import_error="""
61
+ import package_that_does_not_exist
62
+
63
+ def test_unreachable():
64
+ pass
65
+ """
66
+ )
67
+
68
+ result = pytester.runpytest("--why")
69
+
70
+ result.assert_outcomes(errors=1)
71
+ result.stdout.fnmatch_lines(
72
+ [
73
+ "*pytest-why: failure explanations*",
74
+ "Total failures: 1",
75
+ "Import error: test_import_error.py (collect)",
76
+ "*Reports: pytest-why-report.md, pytest-why-report.html*",
77
+ ]
78
+ )
79
+ markdown = (pytester.path / "pytest-why-report.md").read_text(encoding="utf-8")
80
+ html = (pytester.path / "pytest-why-report.html").read_text(encoding="utf-8")
81
+ assert "`import_error` - Import error" in markdown
82
+ assert "**Phase:** `collect`" in markdown
83
+ assert "Import error" in html
84
+ assert "Phase: <code>collect</code>" in html
@@ -0,0 +1,74 @@
1
+ from pytest_why.reporter import write_html_report, write_markdown_report
2
+
3
+
4
+ def sample_failure():
5
+ return {
6
+ "nodeid": "tests/test_demo.py::test_value",
7
+ "phase": "call",
8
+ "duration": 0.01234,
9
+ "longreprtext": "E assert 4 == 5\n<script>alert('x')</script>",
10
+ "type": "assertion_mismatch",
11
+ "title": "Assertion mismatch",
12
+ "explanation": "The result differed from the expectation.",
13
+ "hint": "Inspect the values.",
14
+ }
15
+
16
+
17
+ def test_write_markdown_report(tmp_path):
18
+ path = tmp_path / "report.md"
19
+
20
+ write_markdown_report([sample_failure()], path)
21
+
22
+ content = path.read_text(encoding="utf-8")
23
+ assert "# pytest-why failure explanations" in content
24
+ assert "**Total failures:** 1" in content
25
+ assert "`tests/test_demo.py::test_value`" in content
26
+ assert "**Phase:** `call`" in content
27
+ assert "`assertion_mismatch` - Assertion mismatch" in content
28
+ assert "**Duration:** 0.012s" in content
29
+ assert "**Why:**" in content
30
+ assert "**Hint:**" in content
31
+ assert "<details>" in content
32
+ assert "E assert 4 == 5" in content
33
+
34
+
35
+ def test_write_html_report_escapes_traceback(tmp_path):
36
+ path = tmp_path / "report.html"
37
+
38
+ write_html_report([sample_failure()], path)
39
+
40
+ content = path.read_text(encoding="utf-8")
41
+ assert "<style>" in content
42
+ assert "Total failures:</strong> 1" in content
43
+ assert "Assertion mismatch" in content
44
+ assert "<details>" in content
45
+ assert "<pre>" in content
46
+ assert "&lt;script&gt;alert(&#x27;x&#x27;)&lt;/script&gt;" in content
47
+ assert "<script>alert('x')</script>" not in content
48
+
49
+
50
+ def test_markdown_traceback_uses_a_fence_longer_than_backtick_runs(tmp_path):
51
+ path = tmp_path / "report.md"
52
+ failure = sample_failure()
53
+ failure["longreprtext"] = (
54
+ "Traceback line\n```text\nescaped fence attempt\n```\n"
55
+ "<script>alert('x')</script>"
56
+ )
57
+
58
+ write_markdown_report([failure], path)
59
+
60
+ content = path.read_text(encoding="utf-8")
61
+ assert "````text\nTraceback line\n```text" in content
62
+ assert "\n<script>alert('x')</script>\n````" in content
63
+ assert content.count("````") == 2
64
+
65
+
66
+ def test_markdown_fence_handles_longer_backtick_runs(tmp_path):
67
+ path = tmp_path / "report.md"
68
+ failure = sample_failure()
69
+ failure["longreprtext"] = "before ````` after"
70
+
71
+ write_markdown_report([failure], path)
72
+
73
+ content = path.read_text(encoding="utf-8")
74
+ assert "``````text\nbefore ````` after\n``````" in content