pytest-wirecheck 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.
wirecheck/plugin.py ADDED
@@ -0,0 +1,314 @@
1
+ """pytest plugin: ``pytest --wirecheck``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+ from collections import Counter
10
+ from collections.abc import Sequence
11
+ from types import SimpleNamespace
12
+ from typing import Any
13
+
14
+ import pytest
15
+
16
+ from . import __version__, clock, report, rules, versions
17
+ from .capture import Recorder
18
+ from .rules import Captured
19
+
20
+ STATE_KEY = pytest.StashKey["WireCheck"]()
21
+ FAIL_ON = ("error", "warn", "never")
22
+
23
+
24
+ def pytest_addoption(parser: pytest.Parser) -> None:
25
+ group = parser.getgroup("wirecheck", "Claude API request checks")
26
+ group.addoption(
27
+ "--wirecheck",
28
+ action="store_true",
29
+ default=None,
30
+ help="Capture requests to the Claude API and check them against API version 2026-10-21.",
31
+ )
32
+ group.addoption(
33
+ "--wirecheck-base-url",
34
+ action="append",
35
+ default=[],
36
+ metavar="URL",
37
+ help="Extra base URL to capture (for gateways and proxies). Repeatable. "
38
+ "api.anthropic.com and $ANTHROPIC_BASE_URL are always captured.",
39
+ )
40
+ group.addoption(
41
+ "--wirecheck-fail-on",
42
+ choices=FAIL_ON,
43
+ default=None,
44
+ help="Fail tests on 'error' findings (default), on any finding ('warn'), or 'never'.",
45
+ )
46
+ group.addoption("--wirecheck-report", metavar="PATH", default=None, help="Write findings as JSON.")
47
+ group.addoption("--wirecheck-markdown", metavar="PATH", default=None, help="Write findings as Markdown.")
48
+ parser.addini("wirecheck", type="bool", default=False, help="Turn WireCheck on for every run.")
49
+ parser.addini("wirecheck_base_urls", type="linelist", default=[], help="Extra base URLs to capture.")
50
+ parser.addini("wirecheck_fail_on", default="error", help="error, warn or never.")
51
+
52
+
53
+ def _enabled(config: pytest.Config) -> bool:
54
+ flag = config.getoption("wirecheck")
55
+ if flag is not None:
56
+ return bool(flag)
57
+ if os.environ.get("WIRECHECK", "").strip().lower() in ("1", "true", "yes", "on"):
58
+ return True
59
+ return bool(config.getini("wirecheck"))
60
+
61
+
62
+ def pytest_configure(config: pytest.Config) -> None:
63
+ config.addinivalue_line("markers", "wirecheck_skip: do not check Claude API requests made by this test")
64
+ if not _enabled(config):
65
+ return
66
+ state = WireCheck(config)
67
+ config.stash[STATE_KEY] = state
68
+ config.pluginmanager.register(state, "wirecheck-state")
69
+ state.start()
70
+
71
+
72
+ def pytest_unconfigure(config: pytest.Config) -> None:
73
+ state = config.stash.get(STATE_KEY, None)
74
+ if state is not None:
75
+ state.stop()
76
+
77
+
78
+ class CapturedView(Sequence):
79
+ """Live, read-only list of the requests one test has sent so far."""
80
+
81
+ def __init__(self, state: "WireCheck | None", nodeid: str) -> None:
82
+ self._state = state
83
+ self._nodeid = nodeid
84
+
85
+ def _items(self) -> list[Captured]:
86
+ if self._state is None:
87
+ return []
88
+ return [c for c in self._state.captured if c.test == self._nodeid]
89
+
90
+ def __getitem__(self, index: Any) -> Any:
91
+ return self._items()[index]
92
+
93
+ def __len__(self) -> int:
94
+ return len(self._items())
95
+
96
+ def __repr__(self) -> str:
97
+ return f"CapturedView({self._items()!r})"
98
+
99
+
100
+ @pytest.fixture
101
+ def wirecheck_requests(request: pytest.FixtureRequest) -> CapturedView:
102
+ """Requests to the Claude API captured during this test (always empty if WireCheck is off)."""
103
+ return CapturedView(request.config.stash.get(STATE_KEY, None), request.node.nodeid)
104
+
105
+
106
+ @pytest.fixture
107
+ def wirecheck_stub(request: pytest.FixtureRequest) -> Any:
108
+ """Answer every Claude API request in this test with a canned 200 reply, without the network.
109
+
110
+ This mocks the response, not the client, so WireCheck still sees what your code sends.
111
+ Works with or without --wirecheck. Non-streaming calls only.
112
+ """
113
+ state = request.config.stash.get(STATE_KEY, None)
114
+ own: Recorder | None = None
115
+ if state is None:
116
+ own = Recorder(on_capture=lambda c: None)
117
+ own.install()
118
+ recorder = own
119
+ else:
120
+ recorder = state.recorder
121
+ stub = {"text": "stub reply"}
122
+ recorder.stub = stub
123
+ try:
124
+ yield stub
125
+ finally:
126
+ recorder.stub = None
127
+ if own is not None:
128
+ own.uninstall()
129
+
130
+
131
+ class FailureRepr:
132
+ """Test failure text with a one-line summary for pytest's short test summary."""
133
+
134
+ def __init__(self, text: str, summary: str) -> None:
135
+ self.text = text
136
+ self.reprcrash = SimpleNamespace(message=summary, path="", lineno=0)
137
+
138
+ def toterminal(self, tw: Any) -> None:
139
+ for line in self.text.splitlines():
140
+ tw.line(line)
141
+
142
+ def __str__(self) -> str:
143
+ return self.text
144
+
145
+
146
+ def _one_line(findings: list[rules.Finding]) -> str:
147
+ parts = []
148
+ for f in findings:
149
+ item = f"{f.rule_id} {f.where}"
150
+ if item not in parts:
151
+ parts.append(item)
152
+ shown = ", ".join(parts[:3]) + (f" and {len(parts) - 3} more" if len(parts) > 3 else "")
153
+ return f"WireCheck: {shown} (breaks under Claude API 2026-10-21)"
154
+
155
+
156
+ class WireCheck:
157
+ def __init__(self, config: pytest.Config) -> None:
158
+ self.config = config
159
+ workerinput = getattr(config, "workerinput", None)
160
+ self.worker_id: str | None = workerinput.get("workerid") if workerinput else None
161
+ if workerinput:
162
+ self.share_dir: str | None = workerinput.get("wirecheck_dir")
163
+ self.own_share_dir = False
164
+ else:
165
+ self.share_dir = tempfile.mkdtemp(prefix="wirecheck-")
166
+ self.own_share_dir = True
167
+ self.merged: dict[str, Any] | None = None
168
+ base_urls = list(config.getoption("wirecheck_base_url") or []) + list(config.getini("wirecheck_base_urls"))
169
+ env_base = os.environ.get("ANTHROPIC_BASE_URL")
170
+ if env_base:
171
+ base_urls.append(env_base)
172
+ fail_on = config.getoption("wirecheck_fail_on") or config.getini("wirecheck_fail_on") or "error"
173
+ if fail_on not in FAIL_ON:
174
+ raise pytest.UsageError(f"wirecheck_fail_on must be one of {', '.join(FAIL_ON)}, not {fail_on!r}")
175
+ self.fail_on = fail_on
176
+ self.sdk_version = versions.installed_sdk_version()
177
+ self.recorder = Recorder(self._on_capture, base_urls=base_urls, root=str(config.rootpath))
178
+ self.captured: list[Captured] = []
179
+ self.findings: list[rules.Finding] = []
180
+ self.pending: dict[str, list[rules.Finding]] = {}
181
+ self.skip_tests: set[str] = set()
182
+
183
+ # -- lifecycle -------------------------------------------------------------
184
+ def start(self) -> None:
185
+ self.recorder.install()
186
+
187
+ def stop(self) -> None:
188
+ self.recorder.uninstall()
189
+ if self.own_share_dir and self.share_dir:
190
+ shutil.rmtree(self.share_dir, ignore_errors=True)
191
+
192
+ @pytest.hookimpl(optionalhook=True)
193
+ def pytest_configure_node(self, node: Any) -> None:
194
+ """pytest-xdist: tell each worker where to leave its findings."""
195
+ if self.share_dir:
196
+ node.workerinput["wirecheck_dir"] = self.share_dir
197
+
198
+ def _on_capture(self, captured: Captured) -> None:
199
+ if captured.test in self.skip_tests:
200
+ return
201
+ self.captured.append(captured)
202
+ ctx = rules.Context(sdk_version=self.sdk_version, today=clock.today())
203
+ found = rules.check(captured, ctx)
204
+ self.findings.extend(found)
205
+ if captured.test:
206
+ self.pending.setdefault(captured.test, []).extend(found)
207
+
208
+ def _fails(self, finding: rules.Finding) -> bool:
209
+ if self.fail_on == "never":
210
+ return False
211
+ if self.fail_on == "warn":
212
+ return True
213
+ return finding.severity == rules.ERROR
214
+
215
+ # -- pytest hooks ----------------------------------------------------------
216
+ @pytest.hookimpl(wrapper=True)
217
+ def pytest_runtest_protocol(self, item: pytest.Item, nextitem: Any) -> Any:
218
+ if item.get_closest_marker("wirecheck_skip"):
219
+ self.skip_tests.add(item.nodeid)
220
+ self.recorder.current_test = item.nodeid
221
+ try:
222
+ return (yield)
223
+ finally:
224
+ self.recorder.current_test = None
225
+
226
+ @pytest.hookimpl(wrapper=True)
227
+ def pytest_runtest_makereport(self, item: pytest.Item, call: pytest.CallInfo) -> Any:
228
+ rep = yield
229
+ new = self.pending.pop(item.nodeid, [])
230
+ failing = [f for f in new if self._fails(f)]
231
+ if failing:
232
+ text = report.render_test_failure([f.to_dict() for f in failing])
233
+ if rep.passed:
234
+ rep.outcome = "failed"
235
+ rep.longrepr = FailureRepr(text, _one_line(failing))
236
+ else:
237
+ rep.sections.append(("wirecheck", text))
238
+ return rep
239
+
240
+ @pytest.hookimpl(trylast=True)
241
+ def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None:
242
+ if self.worker_id:
243
+ # pytest-xdist worker: hand the findings to the controller, which reports them.
244
+ if self.share_dir and os.path.isdir(self.share_dir):
245
+ report.dump_json(self.data(), os.path.join(self.share_dir, f"worker-{self.worker_id}.json"))
246
+ return
247
+ data = self.merged = self._merge_workers(self.data())
248
+ outside = [f for f in data["findings"] if not f.get("test") and self._fails_dict(f)]
249
+ if outside and session.exitstatus == 0:
250
+ session.exitstatus = pytest.ExitCode.TESTS_FAILED
251
+ path = self.config.getoption("wirecheck_report")
252
+ if path:
253
+ report.dump_json(data, path)
254
+ md = self.config.getoption("wirecheck_markdown")
255
+ if md:
256
+ with open(md, "w", encoding="utf-8") as fh:
257
+ fh.write(report.render_markdown(data, None))
258
+
259
+ def pytest_report_header(self, config: pytest.Config) -> str:
260
+ libs = ", ".join(self.recorder.libraries) or "none (install httpx2 or httpx)"
261
+ sdk = self.sdk_version or "not installed"
262
+ return f"wirecheck {__version__}: hooked {libs}; anthropic SDK {sdk}; fail on {self.fail_on}"
263
+
264
+ def pytest_terminal_summary(self, terminalreporter: Any) -> None:
265
+ if self.worker_id:
266
+ return
267
+ terminalreporter.write_sep("=", "wirecheck")
268
+ terminalreporter.write_line(report.render_text(self.merged or self.data()))
269
+
270
+ def _fails_dict(self, finding: dict[str, Any]) -> bool:
271
+ if self.fail_on == "never":
272
+ return False
273
+ return self.fail_on == "warn" or finding.get("severity") == rules.ERROR
274
+
275
+ def _merge_workers(self, own: dict[str, Any]) -> dict[str, Any]:
276
+ if not self.share_dir or not os.path.isdir(self.share_dir):
277
+ return own
278
+ merged = dict(
279
+ own,
280
+ findings=list(own["findings"]),
281
+ api_versions=Counter(own["api_versions"]),
282
+ internal_errors=list(own.get("internal_errors", [])),
283
+ )
284
+ for name in sorted(os.listdir(self.share_dir)):
285
+ if not (name.startswith("worker-") and name.endswith(".json")):
286
+ continue
287
+ try:
288
+ with open(os.path.join(self.share_dir, name), encoding="utf-8") as fh:
289
+ part = json.load(fh)
290
+ except (OSError, ValueError):
291
+ continue
292
+ merged["requests_checked"] += part.get("requests_checked", 0)
293
+ merged["api_versions"].update(part.get("api_versions", {}))
294
+ merged["findings"].extend(part.get("findings", []))
295
+ merged["internal_errors"].extend(part.get("internal_errors", []))
296
+ merged["sdk_version"] = merged.get("sdk_version") or part.get("sdk_version")
297
+ merged["api_versions"] = dict(merged["api_versions"])
298
+ return merged
299
+
300
+ def data(self) -> dict[str, Any]:
301
+ versions_seen = Counter(
302
+ versions.resolve(c.headers, self.sdk_version).api_version for c in self.captured
303
+ )
304
+ return {
305
+ "tool": "wirecheck",
306
+ "version": __version__,
307
+ "kind": "pytest",
308
+ "requests_checked": len(self.captured),
309
+ "sdk_version": self.sdk_version,
310
+ "api_versions": dict(versions_seen),
311
+ "fail_on": self.fail_on,
312
+ "findings": [f.to_dict() for f in self.findings],
313
+ "internal_errors": list(self.recorder.errors[:50]),
314
+ }
wirecheck/report.py ADDED
@@ -0,0 +1,183 @@
1
+ """Turn findings into a terminal table, JSON, or a Markdown PR comment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import Counter, OrderedDict
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Iterable
9
+
10
+ from . import __version__
11
+ from .rules import RULES
12
+
13
+ COMMENT_MARKER = "<!-- wirecheck-comment -->"
14
+ REPO_URL = "https://github.com/ishanyash/wirecheck"
15
+ SCAN_RULE_DOCS: dict[str, str] = {} # filled by scan.py to avoid an import cycle
16
+
17
+
18
+ @dataclass
19
+ class Row:
20
+ rule_id: str
21
+ severity: str
22
+ test: str
23
+ call_site: str
24
+ count: int = 0
25
+ where: list[str] = field(default_factory=list)
26
+
27
+
28
+ def group_findings(findings: Iterable[dict[str, Any]]) -> list[Row]:
29
+ rows: "OrderedDict[tuple, Row]" = OrderedDict()
30
+ for f in findings:
31
+ key = (f["rule_id"], f["severity"], f.get("test") or "(outside tests)", f.get("call_site") or "?")
32
+ row = rows.get(key)
33
+ if row is None:
34
+ row = rows[key] = Row(*key)
35
+ row.count += 1
36
+ if f.get("where") and f["where"] not in row.where:
37
+ row.where.append(f["where"])
38
+ order = {"error": 0, "warn": 1}
39
+ return sorted(rows.values(), key=lambda r: (order.get(r.severity, 2), r.rule_id, r.test, r.call_site))
40
+
41
+
42
+ def _counts(findings: Iterable[dict[str, Any]]) -> tuple[int, int]:
43
+ c = Counter(f["severity"] for f in findings)
44
+ return c.get("error", 0), c.get("warn", 0)
45
+
46
+
47
+ def _plural(n: int, word: str) -> str:
48
+ return f"{n} {word}{'' if n == 1 else 's'}"
49
+
50
+
51
+ # -- terminal -------------------------------------------------------------------
52
+
53
+ def render_text(data: dict[str, Any]) -> str:
54
+ findings = data["findings"]
55
+ errors, warns = _counts(findings)
56
+ lines = [
57
+ f"{_plural(data['requests_checked'], 'request')} to the Claude API checked: "
58
+ f"{_plural(errors, 'error')}, {_plural(warns, 'warning')}",
59
+ f"anthropic SDK: {data.get('sdk_version') or 'not installed'}; "
60
+ f"API versions seen: {', '.join(f'{k} x{v}' for k, v in data.get('api_versions', {}).items()) or 'none'}",
61
+ ]
62
+ internal = data.get("internal_errors") or []
63
+ if internal:
64
+ lines.append(
65
+ f"WARNING: WireCheck hit {len(internal)} internal error(s), so some requests may be missing. "
66
+ f"First: {internal[0]}. Please report it at {REPO_URL}/issues"
67
+ )
68
+ if data["requests_checked"] == 0:
69
+ lines.append(
70
+ "No requests were seen. If your tests replace client.messages.create with a mock, no request is "
71
+ "ever built. Mock the HTTP response instead (see the wirecheck_stub fixture)."
72
+ )
73
+ rows = group_findings(findings)
74
+ if rows:
75
+ table = [("RULE", "LEVEL", "COUNT", "TEST", "CALL SITE", "WHAT")]
76
+ for r in rows:
77
+ table.append((r.rule_id, r.severity, str(r.count), r.test, r.call_site, ", ".join(r.where)))
78
+ widths = [max(len(row[i]) for row in table) for i in range(len(table[0]) - 1)]
79
+ lines.append("")
80
+ for row in table:
81
+ cells = [row[i].ljust(widths[i]) for i in range(len(widths))] + [row[-1]]
82
+ lines.append(" ".join(cells).rstrip())
83
+ lines.append("")
84
+ for rule_id in sorted({r.rule_id for r in rows}):
85
+ rule = RULES[rule_id]
86
+ lines.append(f"{rule_id} {rule.name}: {rule.summary}")
87
+ lines.append(f" {rule.doc}")
88
+ return "\n".join(lines)
89
+
90
+
91
+ def render_test_failure(findings: list[dict[str, Any]]) -> str:
92
+ out = ["WireCheck: this test sent a request that breaks under Claude API 2026-10-21", ""]
93
+ for f in findings:
94
+ out.append(f" {f['rule_id']} [{f['severity']}] {f['where']} (api {f['api_version']}, from {f['api_version_source']})")
95
+ out.append(f" call site: {f.get('call_site') or '?'}")
96
+ out.append(f" {f['message']}")
97
+ out.append(f" {f['doc']}")
98
+ return "\n".join(out)
99
+
100
+
101
+ # -- markdown (PR comment and step summary) -------------------------------------
102
+
103
+ def _md(text: str) -> str:
104
+ return str(text).replace("|", "\\|").replace("\n", " ")
105
+
106
+
107
+ def render_markdown(
108
+ pytest_data: dict[str, Any] | None,
109
+ scan_data: dict[str, Any] | None,
110
+ pytest_exit: int | None = None,
111
+ ) -> str:
112
+ runtime = (pytest_data or {}).get("findings", [])
113
+ static = (scan_data or {}).get("findings", [])
114
+ errors, warns = _counts(list(runtime) + list(static))
115
+ if errors:
116
+ headline = f"**{_plural(errors, 'error')}, {_plural(warns, 'warning')}.** These requests return 400 under API version 2026-10-21."
117
+ elif warns:
118
+ headline = f"**No errors, {_plural(warns, 'warning')}.**"
119
+ else:
120
+ headline = "**No problems found.**"
121
+
122
+ parts = [COMMENT_MARKER, "### WireCheck: Claude API 2026-10-21", "", headline, ""]
123
+ facts = []
124
+ if pytest_data is not None:
125
+ facts.append(f"{_plural(pytest_data.get('requests_checked', 0), 'request')} captured in tests")
126
+ if pytest_data.get("sdk_version"):
127
+ facts.append(f"anthropic {pytest_data['sdk_version']}")
128
+ if scan_data is not None:
129
+ facts.append(f"{_plural(scan_data.get('files_scanned', 0), 'file')} scanned")
130
+ if facts:
131
+ parts += ["<sub>" + " · ".join(facts) + "</sub>", ""]
132
+ internal = (pytest_data or {}).get("internal_errors") or []
133
+ if internal:
134
+ parts += [f"> WireCheck hit {len(internal)} internal error(s), so some requests may be missing. "
135
+ f"First: `{_md(internal[0])}`", ""]
136
+ if pytest_exit not in (None, 0, 1, 5):
137
+ parts += [f"> pytest exited with code {pytest_exit}, so some tests may not have run. Check the job log.", ""]
138
+ elif pytest_data is not None and pytest_data.get("requests_checked", 0) == 0:
139
+ parts += [
140
+ "> Your tests sent no requests to the Claude API. If they replace `client.messages.create` with a mock, "
141
+ "no request is ever built. Mock the HTTP response instead (see the `wirecheck_stub` fixture).",
142
+ "",
143
+ ]
144
+
145
+ rows = group_findings(runtime)
146
+ if rows:
147
+ parts += [
148
+ "#### Requests your tests sent",
149
+ "",
150
+ "| Rule | Level | Count | Test | Call site | What |",
151
+ "|---|---|---:|---|---|---|",
152
+ ]
153
+ for r in rows:
154
+ parts.append(
155
+ f"| {r.rule_id} | {r.severity} | {r.count} | `{_md(r.test)}` | `{_md(r.call_site)}` | {_md(', '.join(r.where))} |"
156
+ )
157
+ parts.append("")
158
+
159
+ if static:
160
+ parts += ["#### Static scan", "", "| Rule | Level | File | What |", "|---|---|---|---|"]
161
+ for f in sorted(static, key=lambda x: ({"error": 0, "warn": 1}.get(x["severity"], 2), x["path"], x["line"])):
162
+ parts.append(f"| {f['rule_id']} | {f['severity']} | `{_md(f['path'])}:{f['line']}` | {_md(f['message'])} |")
163
+ parts.append("")
164
+
165
+ used = sorted({f["rule_id"] for f in list(runtime) + list(static)})
166
+ if used:
167
+ parts += ["<details><summary>What the rules mean</summary>", ""]
168
+ for rule_id in used:
169
+ if rule_id in RULES:
170
+ rule = RULES[rule_id]
171
+ parts.append(f"- **{rule_id}** {_md(rule.summary)} [Docs]({rule.doc})")
172
+ elif rule_id in SCAN_RULE_DOCS:
173
+ parts.append(f"- **{rule_id}** {SCAN_RULE_DOCS[rule_id]}")
174
+ parts += ["", "</details>", ""]
175
+
176
+ parts.append(f"<sub>[WireCheck]({REPO_URL}) v{__version__}</sub>")
177
+ return "\n".join(parts) + "\n"
178
+
179
+
180
+ def dump_json(data: dict[str, Any], path: str) -> None:
181
+ with open(path, "w", encoding="utf-8") as fh:
182
+ json.dump(data, fh, indent=2)
183
+ fh.write("\n")