tdd-cli 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.
@@ -0,0 +1,179 @@
1
+ """pytest adapter.
2
+
3
+ Two defects in the prior system are corrected here (R10.2):
4
+
5
+ * `collectors` is inspected, so an import error reports `not_collected` and is never
6
+ conflated with a mistyped identifier (`not_found`). One resolves by writing a stub;
7
+ the other by fixing a name. Conflating them sent agents to ask a human.
8
+ * The report file is written to a fresh temp directory each run, so a stale report can
9
+ never be read as if current.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import shlex
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ from .base import (
20
+ FAILED,
21
+ NOT_COLLECTED,
22
+ NOT_FOUND,
23
+ PASSED,
24
+ Adapter,
25
+ Collection,
26
+ GateResult,
27
+ Verdict,
28
+ run_command,
29
+ )
30
+
31
+ #: Environment-manager marker files, most specific first. A pyproject.toml alone is
32
+ #: NOT a marker: Poetry, pipenv, PDM and plain-venv projects all have one, and
33
+ #: assuming uv there runs the suite in an environment the project never built.
34
+ RUNNER_MARKERS = (
35
+ ("uv.lock", "uv run "),
36
+ ("poetry.lock", "poetry run "),
37
+ ("Pipfile", "pipenv run "),
38
+ ("pdm.lock", "pdm run "),
39
+ )
40
+
41
+
42
+ class PytestAdapter(Adapter):
43
+ name = "pytest"
44
+
45
+ def stub_hint(self) -> str:
46
+ return "`raise NotImplementedError` in every body"
47
+
48
+ def _runner_prefix(self) -> str:
49
+ """The project root is checked before the worktree root: a workspace keeps
50
+ one lockfile at the top, but a member with its own marker owns its choice."""
51
+ for base in (self.root, self.worktree):
52
+ for marker, prefix in RUNNER_MARKERS:
53
+ if (base / marker).is_file():
54
+ return prefix
55
+ pyproject = self.root / "pyproject.toml"
56
+ if pyproject.is_file() and "[tool.poetry]" in pyproject.read_text():
57
+ return "poetry run "
58
+ return ""
59
+
60
+ def _base_cmd(self) -> str:
61
+ return f"{self._runner_prefix()}pytest"
62
+
63
+ def plugin_probe_cmd(self) -> str:
64
+ """The pytest-json-report check, runnable in the project's own environment."""
65
+ return f"{self._runner_prefix()}python -c 'import pytest_jsonreport'"
66
+
67
+ def _test_cmd(self) -> str:
68
+ """The project's own suite command, so the suite under TDD is the real one."""
69
+ return self.project.test_command or self._base_cmd()
70
+
71
+ def _collect_cmd(self) -> str:
72
+ return self.project.collect_command or self._base_cmd()
73
+
74
+ def run(self, target: str | None = None) -> Verdict:
75
+ verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
76
+ with tempfile.TemporaryDirectory(prefix="tdd-pytest-") as tmp:
77
+ report_path = Path(tmp) / "report.json"
78
+ # Only reporting flags are appended — parallelism, markers and plugins
79
+ # stay exactly as the project declared them. json-report is xdist-safe:
80
+ # `collectors` is omitted when nothing fails to collect, and present with
81
+ # the failing entry when something does, which is when it is consulted.
82
+ cmd = (
83
+ f"{self._test_cmd()} --json-report"
84
+ f" --json-report-file={shlex.quote(str(report_path))}"
85
+ )
86
+ code, out, err = self._run_suite(cmd)
87
+ if not report_path.is_file():
88
+ verdict.error = (
89
+ "pytest produced no JSON report (is pytest-json-report installed?): "
90
+ + (err or out)[:500]
91
+ )
92
+ return verdict
93
+ report = json.loads(report_path.read_text())
94
+
95
+ verdict.duration_ms = int(report.get("duration", 0) * 1000)
96
+
97
+ uncollectable: set[str] = set()
98
+ for collector in report.get("collectors", []):
99
+ if collector.get("outcome") not in (None, "passed"):
100
+ uncollectable.add(collector.get("nodeid", ""))
101
+
102
+ for test in report.get("tests", []):
103
+ qualified = self.qualify(test["nodeid"])
104
+ if test["outcome"] == "passed":
105
+ verdict.passed.append(qualified)
106
+ elif test["outcome"] in ("failed", "error"):
107
+ verdict.failed.append(qualified)
108
+
109
+ if target is None:
110
+ verdict.target_outcome = NOT_FOUND
111
+ return verdict
112
+
113
+ native = self.strip(target)
114
+ hit = next(
115
+ (t for t in report.get("tests", []) if t["nodeid"] == native), None
116
+ )
117
+ if hit is not None:
118
+ verdict.target_outcome = PASSED if hit["outcome"] == "passed" else FAILED
119
+ call = hit.get("call") or hit.get("setup") or {}
120
+ verdict.target_failure = str(call.get("longrepr", ""))[:1500]
121
+ else:
122
+ target_file = native.split("::", 1)[0]
123
+ if any(c == target_file or c.startswith(target_file) for c in uncollectable):
124
+ verdict.target_outcome = NOT_COLLECTED
125
+ verdict.target_failure = self._collector_error(report, target_file)[:1500]
126
+ else:
127
+ verdict.target_outcome = NOT_FOUND
128
+ return verdict
129
+
130
+ @staticmethod
131
+ def _collector_error(report: dict, target_file: str) -> str:
132
+ for collector in report.get("collectors", []):
133
+ if collector.get("nodeid", "").startswith(target_file):
134
+ return str(collector.get("longrepr", ""))
135
+ return ""
136
+
137
+ def _test_files(self) -> list[Path]:
138
+ found: list[Path] = []
139
+ for pattern in self.project.test_paths or ["tests/"]:
140
+ if pattern.endswith("/"):
141
+ base = self.root / pattern
142
+ if base.is_dir():
143
+ found.extend(sorted(base.rglob("test_*.py")))
144
+ found.extend(sorted(base.rglob("*_test.py")))
145
+ else:
146
+ found.extend(sorted(self.root.glob(pattern)))
147
+ return sorted({p for p in found if p.is_file()})
148
+
149
+ def collectable(self) -> GateResult:
150
+ """A single whole-suite `--collect-only` (§10), not the per-file
151
+ `collect()` loop below — that loop is R10.3/R10.4's per-file collection,
152
+ the slow path (the whole-suite probe costs 0.04s on a broken project vs.
153
+ minutes for the per-file sweep on a real one).
154
+
155
+ Reads **stdout**, not stderr: `uv` writes environment warnings
156
+ (`VIRTUAL_ENV=... does not match ...`) to stderr while pytest writes the
157
+ actual `ModuleNotFoundError` to stdout. A doctor check that reads stderr
158
+ loses the real error and the failure surfaces unattributed.
159
+ """
160
+ code, out, err = run_command(f"{self._collect_cmd()} --collect-only -q", self.root)
161
+ return GateResult(ok=code == 0, output="" if code == 0 else out.strip()[:2000])
162
+
163
+ def collect(self) -> Collection:
164
+ """Per file (R10.3) — one uncollectable module must not destroy the whole set."""
165
+ result = Collection()
166
+ for path in self._test_files():
167
+ rel = path.relative_to(self.root)
168
+ code, out, err = run_command(
169
+ f"{self._collect_cmd()} --collect-only -q {shlex.quote(str(rel))}",
170
+ self.root,
171
+ )
172
+ if code != 0:
173
+ result.failed_files[str(rel)] = (err or out).strip()[:800]
174
+ continue
175
+ for line in out.splitlines():
176
+ line = line.strip()
177
+ if "::" in line and not line.startswith(("=", "-", "no tests")):
178
+ result.tests.add(self.qualify(line))
179
+ return result
@@ -0,0 +1,170 @@
1
+ """vitest adapter.
2
+
3
+ Test ids are `<worktree-relative file> > <fullName>`, where fullName is the
4
+ space-joined ancestorTitles plus the test title. vitest may prefix its JSON with
5
+ non-JSON lines, so the payload is located rather than assumed (R10.2).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shlex
13
+ from pathlib import Path
14
+
15
+ from .base import (
16
+ FAILED,
17
+ NOT_COLLECTED,
18
+ NOT_FOUND,
19
+ PASSED,
20
+ Adapter,
21
+ Collection,
22
+ GateResult,
23
+ Verdict,
24
+ run_command,
25
+ )
26
+
27
+
28
+ def _extract_json(raw: str) -> dict | None:
29
+ start = raw.find("{")
30
+ while start != -1:
31
+ try:
32
+ return json.loads(raw[start:])
33
+ except json.JSONDecodeError:
34
+ start = raw.find("{", start + 1)
35
+ return None
36
+
37
+
38
+ class VitestAdapter(Adapter):
39
+ name = "vitest"
40
+
41
+ def stub_hint(self) -> str:
42
+ return "`throw new Error(\"not implemented\")` in every body"
43
+
44
+ def _id_for(self, suite_path: str, full_name: str) -> str:
45
+ abs_path = Path(suite_path)
46
+ try:
47
+ rel = os.path.relpath(abs_path, self.worktree)
48
+ except ValueError:
49
+ rel = suite_path
50
+ return self.qualify(f"{rel} > {full_name}")
51
+
52
+ def run(self, target: str | None = None) -> Verdict:
53
+ verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
54
+ base = self.project.test_command or "npx vitest run"
55
+ code, out, err = self._run_suite(f"{base} --reporter=json")
56
+ report = _extract_json(out)
57
+ if report is None:
58
+ verdict.error = f"vitest produced no JSON output: {(err or out)[:500]}"
59
+ return verdict
60
+
61
+ verdict.duration_ms = int(report.get("duration") or 0)
62
+ failed_suites: dict[str, str] = {}
63
+
64
+ for suite in report.get("testResults", []):
65
+ suite_path = suite.get("name", "")
66
+ assertions = suite.get("assertionResults", [])
67
+ if not assertions and suite.get("status") == "failed":
68
+ failed_suites[suite_path] = str(suite.get("message", ""))[:1500]
69
+ for t in assertions:
70
+ qualified = self._id_for(suite_path, t["fullName"])
71
+ if t["status"] == "passed":
72
+ verdict.passed.append(qualified)
73
+ elif t["status"] == "failed":
74
+ verdict.failed.append(qualified)
75
+
76
+ if target is None:
77
+ verdict.target_outcome = NOT_FOUND
78
+ return verdict
79
+
80
+ if target in verdict.passed:
81
+ verdict.target_outcome = PASSED
82
+ return verdict
83
+ if target in verdict.failed:
84
+ verdict.target_outcome = FAILED
85
+ for suite in report.get("testResults", []):
86
+ for t in suite.get("assertionResults", []):
87
+ if self._id_for(suite.get("name", ""), t["fullName"]) == target:
88
+ verdict.target_failure = "\n".join(
89
+ m[:600] for m in t.get("failureMessages", [])[:3]
90
+ )
91
+ return verdict
92
+
93
+ target_file = self.strip(target).split(" > ", 1)[0]
94
+ for suite_path, message in failed_suites.items():
95
+ if suite_path.endswith(target_file):
96
+ verdict.target_outcome = NOT_COLLECTED
97
+ verdict.target_failure = message
98
+ return verdict
99
+ verdict.target_outcome = NOT_FOUND
100
+ return verdict
101
+
102
+ def _test_files(self) -> list[Path]:
103
+ found: set[Path] = set()
104
+ for pattern in self.project.test_paths or ["**/*.test.ts"]:
105
+ pat = pattern.rstrip("/") + "/**/*" if pattern.endswith("/") else pattern
106
+ for path in self.root.glob(pat):
107
+ if path.is_file() and path.suffix in (".ts", ".tsx", ".js", ".jsx"):
108
+ if "node_modules" not in path.parts:
109
+ found.add(path)
110
+ return sorted(found)
111
+
112
+ def _parse_list_output(self, out: str, path: Path) -> set[str]:
113
+ """Parse `vitest list` text output into ids matching those `run()` produces.
114
+
115
+ `vitest list` ignores `--reporter=json` (checked against vitest 4.1.0) and
116
+ emits one line per test:
117
+
118
+ relative/file.test.ts > describe > nested describe > it name
119
+
120
+ `run()` reports the same test with `fullName`, which is the ancestor titles
121
+ and the title joined by a **space**. Splitting on " > " and rejoining the
122
+ name parts with a space reproduces that exactly; keeping the arrows would
123
+ yield ids that never match a verdict.
124
+ """
125
+ found: set[str] = set()
126
+ for line in out.splitlines():
127
+ line = line.strip()
128
+ if " > " not in line:
129
+ continue
130
+ _, _, remainder = line.partition(" > ")
131
+ full_name = " ".join(part.strip() for part in remainder.split(" > "))
132
+ if full_name:
133
+ found.add(self._id_for(str(path), full_name))
134
+ return found
135
+
136
+ def collectable(self) -> GateResult:
137
+ """A single whole-suite probe, mirroring the pytest adapter's `--collect-only`
138
+ (§10): `npx vitest list` at the project root rather than the
139
+ per-file `collect()` loop below."""
140
+ base = self.project.collect_command or "npx vitest list"
141
+ code, out, err = run_command(base, self.root)
142
+ return GateResult(ok=code == 0, output="" if code == 0 else (err or out).strip()[:2000])
143
+
144
+ def collect(self) -> Collection:
145
+ result = Collection()
146
+ for path in self._test_files():
147
+ rel = path.relative_to(self.root)
148
+ base = self.project.collect_command or "npx vitest list"
149
+ code, out, err = run_command(f"{base} {shlex.quote(str(rel))}", self.root)
150
+
151
+ payload = _extract_json(out)
152
+ if payload is not None:
153
+ entries = payload if isinstance(payload, list) else payload.get("tests", [])
154
+ for entry in entries or []:
155
+ name = entry.get("fullName") or entry.get("name")
156
+ if name:
157
+ result.tests.add(self._id_for(str(path), name))
158
+ continue
159
+
160
+ names = self._parse_list_output(out, path)
161
+ if names:
162
+ result.tests.update(names)
163
+ else:
164
+ # Zero tests from a file that exists is a tooling failure, not an
165
+ # empty file — record it rather than silently collecting nothing.
166
+ result.failed_files[str(rel)] = (
167
+ f"no tests parsed from `{base}` (exit {code}): "
168
+ + (err or out).strip()[:600]
169
+ )
170
+ return result