hookhappens 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,3 @@
1
+ """HookHappens: the Python client and pytest plugin. The service lives at hookhappens.com."""
2
+
3
+ __version__ = "0.1.0"
hookhappens/client.py ADDED
@@ -0,0 +1,441 @@
1
+ """Drive HookHappens from tests: a namespace of your own, scenarios and rules, and assertions on
2
+ the calls your system made.
3
+
4
+ from hookhappens.client import HookHappens, gt
5
+
6
+ with HookHappens("https://hookhappens.com") as hh, hh.new_namespace("checkout") as ns:
7
+ ns.scenario("bank-down") # change the answers for this test
8
+ with ns.expect_call("authorize-payment", where={"body.amount": gt(0)}) as got:
9
+ shop.checkout(payments_url=ns.url, amount=50) # your system, pointed at ns.url
10
+ assert got.call.status == 503
11
+
12
+ When the expected call doesn't come, the failure says which calls came closest and what was
13
+ different about each (``CallNotReceived``).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from collections.abc import Iterator, Mapping, Sequence
21
+ from contextlib import contextmanager, suppress
22
+ from dataclasses import dataclass, field
23
+ from types import TracebackType
24
+ from typing import Any
25
+
26
+ import httpx2
27
+
28
+ __all__ = [
29
+ "Call", "CallNotReceived", "Expectation", "HookHappens", "HookHappensError", "Namespace",
30
+ "Op", "UnexpectedCall", "contains", "exists", "gt", "gte", "lt", "lte", "matches",
31
+ "missing", "none_of", "not_equal", "one_of",
32
+ ]
33
+
34
+ Where = Mapping[str, Any] | Sequence[Mapping[str, Any]] | None
35
+
36
+
37
+ # ── conditions: {"body.amount": gt(100)} reads as "body.amount > 100" ──
38
+
39
+ @dataclass(frozen=True)
40
+ class Op:
41
+ """A comparison for a ``where`` value other than "equals"."""
42
+
43
+ op: str
44
+ value: Any
45
+
46
+
47
+ def gt(v: Any) -> Op:
48
+ return Op("gt", v)
49
+
50
+
51
+ def gte(v: Any) -> Op:
52
+ return Op("gte", v)
53
+
54
+
55
+ def lt(v: Any) -> Op:
56
+ return Op("lt", v)
57
+
58
+
59
+ def lte(v: Any) -> Op:
60
+ return Op("lte", v)
61
+
62
+
63
+ def not_equal(v: Any) -> Op:
64
+ return Op("not_equals", v)
65
+
66
+
67
+ def one_of(*values: Any) -> Op:
68
+ return Op("in", list(values))
69
+
70
+
71
+ def none_of(*values: Any) -> Op:
72
+ return Op("not_in", list(values))
73
+
74
+
75
+ def contains(v: Any) -> Op:
76
+ return Op("contains", v)
77
+
78
+
79
+ def matches(pattern: str) -> Op:
80
+ return Op("matches", pattern)
81
+
82
+
83
+ def exists() -> Op:
84
+ return Op("exists", True)
85
+
86
+
87
+ def missing() -> Op:
88
+ return Op("exists", False)
89
+
90
+
91
+ def conditions(where: Where) -> list[dict[str, Any]]:
92
+ """``{"body.amount": 50, "body.currency": one_of("EUR", "USD")}`` or the catalog's own
93
+ form ``[{"field": "body.amount", "gt": 100}]`` → the catalog's form."""
94
+ if where is None:
95
+ return []
96
+ if isinstance(where, Mapping):
97
+ return [{"field": f, v.op: v.value} if isinstance(v, Op) else {"field": f, "equals": v}
98
+ for f, v in where.items()]
99
+ return [dict(c) for c in where]
100
+
101
+
102
+ _SYMBOLS = {"equals": "=", "not_equals": "!=", "gt": ">", "gte": ">=", "lt": "<", "lte": "<=",
103
+ "in": "in", "not_in": "not in", "contains": "contains", "matches": "matches"}
104
+
105
+
106
+ def _show(v: Any) -> str:
107
+ return json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else repr(v)
108
+
109
+
110
+ def describe_condition(c: Mapping[str, Any]) -> str:
111
+ op = next((k for k in c if k != "field"), "equals")
112
+ if op == "exists":
113
+ return f"{c['field']} {'exists' if c[op] else 'is missing'}"
114
+ return f"{c['field']} {_SYMBOLS.get(op, op)} {_show(c[op])}"
115
+
116
+
117
+ # ── calls ──
118
+
119
+ @dataclass(frozen=True)
120
+ class Call:
121
+ """One call your system made to HookHappens, and what it got back."""
122
+
123
+ id: int
124
+ received_at: str
125
+ method: str
126
+ path: str
127
+ endpoint: str | None
128
+ response_name: str | None
129
+ status: int
130
+ duration_ms: int
131
+ request: dict[str, Any]
132
+ sent: dict[str, Any]
133
+ issues: list[dict[str, Any]]
134
+
135
+ @classmethod
136
+ def from_api(cls, d: Mapping[str, Any]) -> Call:
137
+ return cls(d["id"], d["receivedAt"], d["method"], d["path"], d.get("endpoint"),
138
+ d.get("response"), d["status"], d.get("durationMs", 0),
139
+ d.get("request") or {}, d.get("sent") or {}, d.get("issues") or [])
140
+
141
+ @property
142
+ def body(self) -> Any:
143
+ """The request body your system sent."""
144
+ return self.request.get("body")
145
+
146
+ @property
147
+ def headers(self) -> dict[str, str]:
148
+ return dict(self.request.get("headers") or {})
149
+
150
+ @property
151
+ def query(self) -> dict[str, Any]:
152
+ return dict(self.request.get("query") or {})
153
+
154
+ @property
155
+ def params(self) -> dict[str, str]:
156
+ return dict(self.request.get("params") or {})
157
+
158
+ @property
159
+ def response_body(self) -> Any:
160
+ """What HookHappens answered (after any rules)."""
161
+ return self.sent.get("body")
162
+
163
+ def __str__(self) -> str:
164
+ where = f" ({self.endpoint})" if self.endpoint else ""
165
+ return f"#{self.id} {self.method} {self.path} -> {self.status}{where}"
166
+
167
+
168
+ class HookHappensError(Exception):
169
+ """The server refused a request, or could not be reached."""
170
+
171
+
172
+ class CallNotReceived(AssertionError):
173
+ """The call a test waited for didn't come. The message lists the closest calls."""
174
+
175
+ def __init__(self, message: str, result: Mapping[str, Any]) -> None:
176
+ super().__init__(message)
177
+ self.result = dict(result)
178
+
179
+
180
+ class UnexpectedCall(AssertionError):
181
+ """A call came that the test said must not."""
182
+
183
+ def __init__(self, message: str, calls: list[Call]) -> None:
184
+ super().__init__(message)
185
+ self.calls = calls
186
+
187
+
188
+ def _what(endpoint: str | None, method: str | None, path: str | None,
189
+ conds: list[dict[str, Any]]) -> str:
190
+ target = (f"call to {endpoint}" if endpoint
191
+ else f"{method or ''} {path}".strip() + " call" if path or method else "call")
192
+ return target + (" where " + " and ".join(map(describe_condition, conds)) if conds else "")
193
+
194
+
195
+ def _check_line(c: Mapping[str, Any]) -> str:
196
+ mark = "+" if c["ok"] else "x"
197
+ if "op" not in c: # endpoint / method / path
198
+ return (f"{mark} {c['check']} = {c['expected']}" if c["ok"]
199
+ else f"{mark} {c['check']}: expected {c['expected']}, got {c['actual']}")
200
+ text = describe_condition({"field": c["check"], c["op"]: c["expected"]})
201
+ if c["ok"]:
202
+ return f"{mark} {text}"
203
+ return f"{mark} {text}: got {_show(c['actual']) if c.get('found') else 'nothing'}"
204
+
205
+
206
+ def explain_miss(what: str, timeout: float, namespace: str, result: Mapping[str, Any]) -> str:
207
+ """The failure message: what was expected, and the calls that came closest."""
208
+ seen = result.get("seen", 0)
209
+ lines = [f"No {what} arrived within {timeout:g} s "
210
+ f"(namespace {namespace}; {seen} call{'s' if seen != 1 else ''} seen)."]
211
+ misses = result.get("nearMisses") or []
212
+ if not misses:
213
+ lines.append("No calls at all came in: is your system pointed at "
214
+ f"/h/{namespace}/ ?" if not seen else "")
215
+ else:
216
+ lines.append("Closest:")
217
+ for m in misses:
218
+ c = m["call"]
219
+ checks = m["checks"]
220
+ passed = sum(x["ok"] for x in checks)
221
+ where = f" ({c['endpoint']})" if c.get("endpoint") else ""
222
+ lines.append(f" #{c['id']} {c['method']} {c['path']} -> {c['status']}{where}"
223
+ f" {passed} of {len(checks)} checks")
224
+ lines += [f" {_check_line(x)}" for x in checks]
225
+ return "\n".join(line for line in lines if line)
226
+
227
+
228
+ # ── the client ──
229
+
230
+ class HookHappens:
231
+ """HookHappens, for tests. Use ``new_namespace`` for a namespace of your own per test.
232
+
233
+ ``api_key`` is your workspace's API key (default: the ``HOOKHAPPENS_API_KEY`` environment
234
+ variable); every call sends it as ``Authorization: Bearer …``."""
235
+
236
+ def __init__(self, url: str = "https://hookhappens.com", *, api_key: str | None = None,
237
+ timeout: float = 30.0, http: httpx2.Client | None = None) -> None:
238
+ self.url = url.rstrip("/")
239
+ key = api_key or os.environ.get("HOOKHAPPENS_API_KEY")
240
+ headers = {"Authorization": f"Bearer {key}"} if key else {}
241
+ self._http = http or httpx2.Client(base_url=self.url, timeout=timeout, headers=headers)
242
+
243
+ def close(self) -> None:
244
+ self._http.close()
245
+
246
+ def __enter__(self) -> HookHappens:
247
+ return self
248
+
249
+ def __exit__(self, *exc: object) -> None:
250
+ self.close()
251
+
252
+ def request(self, method: str, path: str, body: Any = None,
253
+ params: Mapping[str, Any] | None = None) -> Any:
254
+ try:
255
+ r = self._http.request(method, path, json=body, params=params)
256
+ except httpx2.HTTPError as exc:
257
+ raise HookHappensError(
258
+ f"HookHappens is not reachable at {self.url} ({exc}). Check the address "
259
+ "(HOOKHAPPENS_URL) and the network.") from exc
260
+ if r.status_code >= 400:
261
+ try:
262
+ data = r.json()
263
+ problems = "".join(f"\n {p['location']}: {p['message']}"
264
+ for p in data.get("problems", []))
265
+ message = f"{data.get('message', r.text)}{problems}"
266
+ except ValueError:
267
+ message = r.text
268
+ raise HookHappensError(f"{method} {path} -> {r.status_code}: {message}")
269
+ return r.json() if r.content else None
270
+
271
+ def health(self) -> dict[str, Any]:
272
+ out: dict[str, Any] = self.request("GET", "/health")
273
+ return out
274
+
275
+ def namespace(self, namespace: str) -> Namespace:
276
+ """An existing namespace (e.g. the project's own). Left as it is on close."""
277
+ return Namespace(self, namespace, owned=False)
278
+
279
+ def new_namespace(self, project: str, *, id: str | None = None,
280
+ ttl: int | None = 3600) -> Namespace:
281
+ """A fresh namespace of ``project``: its own URL, rules and call log. Deleted on close
282
+ (or ``ttl`` seconds after the last keep-alive, if the test never gets there)."""
283
+ data = self.request("POST", f"/api/v1/projects/{project}/namespaces",
284
+ {"id": id, "ttl_seconds": ttl})
285
+ return Namespace(self, data["id"], project=project, owned=True)
286
+
287
+
288
+ @dataclass
289
+ class Expectation:
290
+ """Filled when the ``with ns.expect_call(...)`` block ends: the call that came."""
291
+
292
+ call: Call = field(init=False)
293
+
294
+
295
+ class Namespace:
296
+ """One namespace: where your system sends its calls (``url``), and what the test controls."""
297
+
298
+ def __init__(self, hh: HookHappens, namespace: str, *, project: str | None = None,
299
+ owned: bool = False) -> None:
300
+ self.hh = hh
301
+ self.id = namespace
302
+ self.project = project
303
+ self.owned = owned
304
+
305
+ @property
306
+ def url(self) -> str:
307
+ """The base URL to give your system: the namespace's address, …/h/checkout-3fa2c1"""
308
+ return f"{self.hh.url}/h/{self.id}"
309
+
310
+ def __repr__(self) -> str:
311
+ return f"Namespace({self.id!r})"
312
+
313
+ def __enter__(self) -> Namespace:
314
+ return self
315
+
316
+ def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None,
317
+ tb: TracebackType | None) -> None:
318
+ self.close()
319
+
320
+ def close(self) -> None:
321
+ """Delete it, if this client made it."""
322
+ if self.owned:
323
+ with suppress(HookHappensError): # already gone (expired) is fine
324
+ self.hh.request("DELETE", f"/api/v1/namespaces/{self.id}")
325
+ self.owned = False
326
+
327
+ def keep_alive(self, ttl: int = 3600) -> None:
328
+ self.hh.request("POST", f"/api/v1/namespaces/{self.id}/keep-alive", {"ttl_seconds": ttl})
329
+
330
+ # ── what it answers ──
331
+
332
+ def scenario(self, scenario_id: str, **settings: Any) -> None:
333
+ """This scenario of the catalog, and nothing else: other scenarios and the rules armed
334
+ here are switched off. Keyword arguments set its settings, e.g.
335
+ ``ns.scenario("skip-steps", stepTypes=["table"])``."""
336
+ if settings:
337
+ self.scenario_settings(scenario_id, **settings)
338
+ self.hh.request("PUT", f"/api/v1/namespaces/{self.id}/active-scenario", {"id": scenario_id})
339
+
340
+ def scenario_on(self, scenario_id: str, **settings: Any) -> None:
341
+ """Switch a scenario on as well (any number can be on), with these settings."""
342
+ self._switch(scenario_id, {"active": True, **({"settings": settings} if settings else {})})
343
+
344
+ def scenario_off(self, scenario_id: str) -> None:
345
+ self._switch(scenario_id, {"active": False})
346
+
347
+ def scenario_settings(self, scenario_id: str, **settings: Any) -> None:
348
+ """Set a scenario's settings here (``None`` puts one back to its default)."""
349
+ self._switch(scenario_id, {"settings": settings})
350
+
351
+ def _switch(self, scenario_id: str, body: dict[str, Any]) -> None:
352
+ self.hh.request("PUT", f"/api/v1/namespaces/{self.id}/scenarios/{scenario_id}", body)
353
+
354
+ def clear_rules(self) -> None:
355
+ """Back to the catalog's own answers (no scenario, no rules)."""
356
+ self.hh.request("DELETE", f"/api/v1/namespaces/{self.id}/active-scenario")
357
+
358
+ def rule(self, endpoint: str | None = None, *, then: Sequence[Mapping[str, Any]],
359
+ when: Where = None, skip: int = 0, times: int | None = None,
360
+ description: str | None = None) -> str:
361
+ """Add one rule (see the scenario rules docs for ``then`` actions). Returns its id."""
362
+ body: dict[str, Any] = {"endpoint": endpoint, "then": [dict(a) for a in then],
363
+ "when": conditions(when), "skip": skip, "times": times,
364
+ "description": description}
365
+ out = self.hh.request("POST", f"/api/v1/namespaces/{self.id}/rules",
366
+ {k: v for k, v in body.items() if v not in (None, [])})
367
+ return str(out["id"])
368
+
369
+ # ── what it received ──
370
+
371
+ def calls(self, endpoint: str | None = None, *, limit: int = 200) -> list[Call]:
372
+ """The calls in the log, oldest first."""
373
+ data = self.hh.request("GET", f"/api/v1/namespaces/{self.id}/calls",
374
+ params={"limit": limit})
375
+ calls = [Call.from_api(c) for c in reversed(data["calls"])]
376
+ return [c for c in calls if endpoint is None or c.endpoint == endpoint]
377
+
378
+ def mark(self) -> int:
379
+ """The newest call's id now (0 if none): pass it as ``after`` to wait only for later
380
+ calls."""
381
+ data = self.hh.request("GET", f"/api/v1/namespaces/{self.id}/calls", params={"limit": 1})
382
+ return int(data["calls"][0]["id"]) if data["calls"] else 0
383
+
384
+ def _wait(self, endpoint: str | None, where: Where, method: str | None, path: str | None,
385
+ after: int | None, timeout: float, count: int) -> tuple[dict[str, Any], str]:
386
+ conds = conditions(where)
387
+ result: dict[str, Any] = self.hh.request(
388
+ "POST", f"/api/v1/namespaces/{self.id}/calls/wait",
389
+ {"endpoint": endpoint, "where": conds, "method": method, "path": path,
390
+ "after": after, "count": count, "timeout_ms": int(timeout * 1000)})
391
+ return result, _what(endpoint, method, path, conds)
392
+
393
+ def wait_for(self, endpoint: str | None = None, *, where: Where = None,
394
+ method: str | None = None, path: str | None = None, after: int | None = None,
395
+ timeout: float = 5.0) -> Call:
396
+ """The first call that fits (already in the log, or arriving within ``timeout``).
397
+ Raises ``CallNotReceived``, listing the closest calls, when none does."""
398
+ __tracebackhide__ = True # pytest shows the test's line, not these helpers
399
+ return self.wait_for_all(1, endpoint, where=where, method=method, path=path,
400
+ after=after, timeout=timeout)[0]
401
+
402
+ def wait_for_all(self, count: int, endpoint: str | None = None, *, where: Where = None,
403
+ method: str | None = None, path: str | None = None,
404
+ after: int | None = None, timeout: float = 5.0) -> list[Call]:
405
+ """``count`` calls that fit, oldest first."""
406
+ __tracebackhide__ = True
407
+ result, what = self._wait(endpoint, where, method, path, after, timeout, count)
408
+ if not result["matched"]:
409
+ found = len(result["calls"])
410
+ label = what if count == 1 else f"{count} x {what} (only {found} came)"
411
+ raise CallNotReceived(explain_miss(label, timeout, self.id, result), result)
412
+ return [Call.from_api(c) for c in result["calls"]]
413
+
414
+ @contextmanager
415
+ def expect_call(self, endpoint: str | None = None, *, where: Where = None,
416
+ method: str | None = None, path: str | None = None,
417
+ timeout: float = 5.0) -> Iterator[Expectation]:
418
+ """The block must make your system send this call (only calls made inside count)."""
419
+ __tracebackhide__ = True
420
+ after = self.mark()
421
+ got = Expectation()
422
+ yield got
423
+ got.call = self.wait_for(endpoint, where=where, method=method, path=path, after=after,
424
+ timeout=timeout)
425
+
426
+ def expect_no_call(self, endpoint: str | None = None, *, where: Where = None,
427
+ method: str | None = None, path: str | None = None,
428
+ after: int | None = None, within: float = 1.0) -> None:
429
+ """No such call (in the log after ``after``, or arriving within ``within`` seconds)."""
430
+ __tracebackhide__ = True
431
+ result, what = self._wait(endpoint, where, method, path, after, within, 1)
432
+ if result["matched"]:
433
+ calls = [Call.from_api(c) for c in result["calls"]]
434
+ raise UnexpectedCall(f"Expected no {what}, but got {calls[0]} (namespace {self.id}).",
435
+ calls)
436
+
437
+ def evidence(self) -> dict[str, Any]:
438
+ """Everything about this namespace's calls, for a test report."""
439
+ data = self.hh.request("GET", f"/api/v1/namespaces/{self.id}/calls", params={"limit": 500})
440
+ return {"namespace": self.id, "project": self.project, "url": self.url,
441
+ "calls": list(reversed(data["calls"]))}
hookhappens/py.typed ADDED
File without changes
@@ -0,0 +1,195 @@
1
+ """pytest plugin: each test gets a fresh HookHappens namespace, and a failing test shows the
2
+ calls its system made.
3
+
4
+ # pytest.ini (or --hookhappens-url / --hookhappens-project, or HOOKHAPPENS_URL)
5
+ [pytest]
6
+ hookhappens_url = https://hookhappens.com
7
+ hookhappens_project = checkout
8
+
9
+ def test_paid(hh): # hh: a namespace of its own, deleted afterwards
10
+ assert shop.checkout(hh.url, amount=50) == "paid"
11
+ hh.wait_for("authorize-payment", where={"body.amount": 50})
12
+
13
+ @pytest.mark.hookhappens(scenario="bank-down")
14
+ def test_bank_down(hh): ...
15
+
16
+ With ``--hookhappens-evidence DIR``, every test's calls are written to DIR as JSON, with an
17
+ ``index.html`` to browse them.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import html
23
+ import json
24
+ import os
25
+ import re
26
+ from collections.abc import Generator, Iterator
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ import pytest
31
+
32
+ from hookhappens.client import HookHappens, HookHappensError, Namespace
33
+
34
+ NAMESPACE = pytest.StashKey[Namespace]()
35
+ MARKER = "hookhappens"
36
+
37
+
38
+ def pytest_addoption(parser: pytest.Parser) -> None:
39
+ group = parser.getgroup("hookhappens", "HookHappens")
40
+ group.addoption("--hookhappens-url", default=None, metavar="URL",
41
+ help="the HookHappens server (default: $HOOKHAPPENS_URL, the "
42
+ "hookhappens_url ini value, or https://hookhappens.com)")
43
+ group.addoption("--hookhappens-project", default=None, metavar="ID",
44
+ help="the project tests get namespaces of (or the hookhappens_project "
45
+ "ini value, or @pytest.mark.hookhappens(project=...))")
46
+ group.addoption("--hookhappens-evidence", default=None, metavar="DIR",
47
+ help="write each test's calls to DIR (JSON per test + index.html)")
48
+ parser.addini("hookhappens_url", "the HookHappens server", default="")
49
+ parser.addini("hookhappens_project", "the project tests get namespaces of", default="")
50
+
51
+
52
+ def pytest_configure(config: pytest.Config) -> None:
53
+ config.addinivalue_line(
54
+ "markers", f"{MARKER}(project=None, scenario=None, ttl=3600): the HookHappens "
55
+ "namespace the `hh` fixture makes for this test")
56
+
57
+
58
+ def _setting(config: pytest.Config, name: str, env: str | None = None) -> str | None:
59
+ value = config.getoption(f"--hookhappens-{name}")
60
+ if value:
61
+ return str(value)
62
+ if env and os.environ.get(env):
63
+ return os.environ[env]
64
+ ini = config.getini(f"hookhappens_{name}")
65
+ return str(ini) if ini else None
66
+
67
+
68
+ @pytest.fixture(scope="session")
69
+ def hookhappens(pytestconfig: pytest.Config) -> Iterator[HookHappens]:
70
+ """The HookHappens server (a client for it), checked to be up."""
71
+ url = _setting(pytestconfig, "url", "HOOKHAPPENS_URL") or "https://hookhappens.com"
72
+ client = HookHappens(url)
73
+ try:
74
+ client.health()
75
+ except HookHappensError as exc:
76
+ client.close()
77
+ pytest.fail(str(exc), pytrace=False)
78
+ yield client
79
+ client.close()
80
+
81
+
82
+ @pytest.fixture
83
+ def hh(request: pytest.FixtureRequest, hookhappens: HookHappens) -> Iterator[Namespace]:
84
+ """A fresh namespace of the project for this test: its own URL (``hh.url``), rules and
85
+ call log. Deleted after the test."""
86
+ marker = request.node.get_closest_marker(MARKER)
87
+ options: dict[str, Any] = dict(marker.kwargs) if marker else {}
88
+ project = options.get("project") or _setting(request.config, "project")
89
+ if not project:
90
+ pytest.fail("Which HookHappens project? Set hookhappens_project in pytest.ini, pass "
91
+ "--hookhappens-project, or mark the test "
92
+ "@pytest.mark.hookhappens(project=...)", pytrace=False)
93
+ ns = hookhappens.new_namespace(project, ttl=options.get("ttl", 3600))
94
+ request.node.stash[NAMESPACE] = ns
95
+ try:
96
+ if options.get("scenario"):
97
+ ns.scenario(options["scenario"])
98
+ yield ns
99
+ finally:
100
+ ns.close()
101
+
102
+
103
+ @pytest.hookimpl(wrapper=True)
104
+ def pytest_runtest_makereport(
105
+ item: pytest.Item, call: pytest.CallInfo[None],
106
+ ) -> Generator[None, pytest.TestReport, pytest.TestReport]:
107
+ report = yield
108
+ ns = item.stash.get(NAMESPACE, None)
109
+ if ns is None or report.when != "call":
110
+ return report
111
+ evidence_dir = item.config.getoption("--hookhappens-evidence")
112
+ if not (report.failed or evidence_dir):
113
+ return report
114
+ try:
115
+ evidence = ns.evidence() # before the fixture deletes the namespace
116
+ except HookHappensError as exc:
117
+ report.sections.append(("HookHappens calls", f"(could not read them: {exc})"))
118
+ return report
119
+ if report.failed:
120
+ report.sections.append((f"HookHappens calls (namespace {ns.id})", _calls_text(evidence)))
121
+ if evidence_dir:
122
+ _write_evidence(Path(evidence_dir), item.nodeid, report.outcome, evidence)
123
+ return report
124
+
125
+
126
+ def _calls_text(evidence: dict[str, Any]) -> str:
127
+ calls = evidence["calls"]
128
+ if not calls:
129
+ return f"No calls came in. Was your system pointed at {evidence['url']} ?"
130
+ lines = []
131
+ for c in calls:
132
+ where = f" ({c['endpoint']})" if c.get("endpoint") else ""
133
+ lines.append(f"#{c['id']} {c['method']} {c['path']} -> {c['status']}{where}")
134
+ body = (c.get("request") or {}).get("body")
135
+ if body is not None:
136
+ lines.append(f" sent: {json.dumps(body, ensure_ascii=False)[:300]}")
137
+ answer = (c.get("sent") or {}).get("body")
138
+ if answer is not None:
139
+ lines.append(f" answered: {json.dumps(answer, ensure_ascii=False)[:300]}")
140
+ rules = (c.get("sent") or {}).get("rules")
141
+ if rules:
142
+ lines.append(f" rules: {', '.join(rules)}")
143
+ for issue in c.get("issues") or []:
144
+ lines.append(f" ! {issue['location']}: {issue['message']}")
145
+ return "\n".join(lines)
146
+
147
+
148
+ def _file_name(nodeid: str) -> str:
149
+ return re.sub(r"[^A-Za-z0-9_.-]+", "_", nodeid).strip("_")[:150] + ".json"
150
+
151
+
152
+ def _write_evidence(folder: Path, nodeid: str, outcome: str, evidence: dict[str, Any]) -> None:
153
+ folder.mkdir(parents=True, exist_ok=True)
154
+ (folder / _file_name(nodeid)).write_text(
155
+ json.dumps({"test": nodeid, "outcome": outcome, **evidence}, indent=2,
156
+ ensure_ascii=False), encoding="utf-8")
157
+
158
+
159
+ def pytest_sessionfinish(session: pytest.Session) -> None:
160
+ folder = session.config.getoption("--hookhappens-evidence")
161
+ if not folder or hasattr(session.config, "workerinput"): # once, not per xdist worker
162
+ return
163
+ path = Path(folder)
164
+ if path.is_dir():
165
+ (path / "index.html").write_text(evidence_index(path), encoding="utf-8")
166
+
167
+
168
+ _PAGE_STYLE = (
169
+ "body{font:14px system-ui;margin:24px}details{margin:6px 0;padding:6px 10px;"
170
+ "border-radius:8px;background:#f4f2fb}details.failed{background:#fde8e8}"
171
+ "pre{background:#fff;padding:6px;border-radius:6px;max-height:240px;overflow:auto}")
172
+
173
+
174
+ def _call_item(c: dict[str, Any]) -> str:
175
+ e = html.escape
176
+ sent = json.dumps((c.get("request") or {}).get("body"), indent=2, ensure_ascii=False)
177
+ return (f"<li><code>{e(c['method'])} {e(c['path'])}</code> → {c['status']} "
178
+ f"{e(c.get('endpoint') or '')}<pre>{e(sent)}</pre></li>")
179
+
180
+
181
+ def _test_block(data: dict[str, Any], file_name: str) -> str:
182
+ e = html.escape
183
+ calls = "".join(_call_item(c) for c in data["calls"]) or "<li>no calls</li>"
184
+ return (f"<details class='{e(data['outcome'])}'><summary><b>{e(data['outcome'])}</b> "
185
+ f"{e(data['test'])} <small>({len(data['calls'])} calls, namespace "
186
+ f"{e(data['namespace'])}) · <a href='{e(file_name)}'>JSON</a></small></summary>"
187
+ f"<ul>{calls}</ul></details>")
188
+
189
+
190
+ def evidence_index(folder: Path) -> str:
191
+ """One page listing every test's calls (from the JSON files the run wrote)."""
192
+ blocks = [_test_block(json.loads(f.read_text(encoding="utf-8")), f.name)
193
+ for f in sorted(folder.glob("*.json"))]
194
+ return ("<!doctype html><meta charset='utf-8'><title>HookHappens evidence</title>"
195
+ f"<style>{_PAGE_STYLE}</style><h1>HookHappens evidence</h1>" + "".join(blocks))
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.5
2
+ Name: hookhappens
3
+ Version: 0.1.0
4
+ Summary: The Python client and pytest plugin for HookHappens, the webhook and API mock for testing at hookhappens.com.
5
+ Project-URL: Homepage, https://hookhappens.com
6
+ Project-URL: Documentation, https://hookhappens.com/#/how-to/testing
7
+ License-Expression: LicenseRef-Proprietary
8
+ Keywords: api,mock,pytest,testing,webhook
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Framework :: Pytest
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Testing
16
+ Classifier: Topic :: Software Development :: Testing :: Mocking
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+
20
+ # HookHappens, the Python client
21
+
22
+ **HookHappens** ("Mock around & find out") stands in for the endpoints your system calls, or
23
+ sits in front of the real ones. A test changes exactly the part of the answer it needs, and you
24
+ see what your system did with it. The service lives at **https://hookhappens.com**; this package
25
+ is the Python client and the pytest plugin for it.
26
+
27
+ ```bash
28
+ pip install hookhappens
29
+ ```
30
+
31
+ ```python
32
+ from hookhappens import HookHappens
33
+
34
+ with HookHappens("https://hookhappens.com", api_key="hh_…") as hh:
35
+ with hh.new_namespace("snack-bar") as run: # a throwaway address for this test run
36
+ run.rule("order-snack", then=[{"status": 503}]) # the kitchen is on fire
37
+ my_app.order("cookie", 3, kitchen=run.url) # point your system at it
38
+ call = run.wait_for("order-snack") # the call your system made, with its answer
39
+ assert call.status == 503
40
+ ```
41
+
42
+ With pytest, the `hh` fixture does the same with no setup beyond two environment variables:
43
+
44
+ ```bash
45
+ export HOOKHAPPENS_URL=https://hookhappens.com
46
+ export HOOKHAPPENS_API_KEY=hh_…
47
+ pytest
48
+ ```
49
+
50
+ ```python
51
+ def test_the_kitchen_on_fire(hh):
52
+ hh.scenario_on("kitchen-on-fire")
53
+ my_app.order("cookie", 3, kitchen=hh.url)
54
+ assert hh.wait_for(endpoint="order-snack").status == 503
55
+ ```
56
+
57
+ The whole story, with a runnable example app and its test suite, is the **Testing your system**
58
+ guide: https://hookhappens.com/#/how-to/testing. The service is hosted only; nothing to install
59
+ but this client. © 2026 Carolina Shulman Grigoriev. All rights reserved.
@@ -0,0 +1,8 @@
1
+ hookhappens/__init__.py,sha256=xwopvWUUEBBFKyPVHr_eM8GD6NNbE1vs_oPTc1oTk_8,120
2
+ hookhappens/client.py,sha256=uBuy5NwnhiUAIk2F82NuSXG_1DmGnGRrhMMy0BCor0U,17820
3
+ hookhappens/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ hookhappens/pytest_plugin.py,sha256=ysTut2m-GDv-sRiHg9yMD1hmw44fXs48jfUwfaIstDQ,8147
5
+ hookhappens-0.1.0.dist-info/METADATA,sha256=_2e2iXEUvdHd2R8PUZIAVSFYrWmU54Lmke5ugOo3Bhc,2406
6
+ hookhappens-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ hookhappens-0.1.0.dist-info/entry_points.txt,sha256=eaQgfbts17b_gc_avsutFevd836SuCpUFYugKdafGCI,51
8
+ hookhappens-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ hookhappens = hookhappens.pytest_plugin