pytest-why 0.1.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.
pytest_why/__init__.py ADDED
@@ -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
pytest_why/plugin.py ADDED
@@ -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")
pytest_why/reporter.py ADDED
@@ -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,10 @@
1
+ pytest_why/__init__.py,sha256=nTl0qlGDZO5YClO-pUfZCXNUPI2FN-4nwQzS9qDcRGg,98
2
+ pytest_why/classifier.py,sha256=owIQGXM8mg8uYPMR4GxzEb0qU8e4m-sy9FQfpehtWC8,4212
3
+ pytest_why/plugin.py,sha256=X-VaWHQEASfHcaQDcGY3aOJc87pKL6bq7IOfhnTQMcY,2547
4
+ pytest_why/reporter.py,sha256=a-nTYF9fRHtQA_un60Z7vmNPQ69C7J4FvQxaBlU4Tis,4844
5
+ pytest_why-0.1.0.dist-info/licenses/LICENSE,sha256=XmPzRh-kKDeQQez3kovjZKId7D2OYf5gZwtxolmjHF0,1080
6
+ pytest_why-0.1.0.dist-info/METADATA,sha256=GTqTROj0AaVYGF9rKuCvo5Bo7IQSrmPGlRsmIU3Cheg,3242
7
+ pytest_why-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ pytest_why-0.1.0.dist-info/entry_points.txt,sha256=t4CtYc-h5dGN-9iRyf81NL-sPSyY7Xj84Y8yEO-wMqw,35
9
+ pytest_why-0.1.0.dist-info/top_level.txt,sha256=n6uxzE_N4YpeRE8ovmIOZFIamBeIQpXEvca5G8FCYA4,11
10
+ pytest_why-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ why = pytest_why.plugin
@@ -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
+ pytest_why