webhook-co 0.2.0__tar.gz → 0.2.1__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.
Files changed (31) hide show
  1. {webhook_co-0.2.0 → webhook_co-0.2.1}/PKG-INFO +1 -1
  2. {webhook_co-0.2.0 → webhook_co-0.2.1}/pyproject.toml +1 -1
  3. webhook_co-0.2.1/scripts/packaged_smoke.py +182 -0
  4. {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/client.py +7 -3
  5. {webhook_co-0.2.0 → webhook_co-0.2.1}/.gitignore +0 -0
  6. {webhook_co-0.2.0 → webhook_co-0.2.1}/README.md +0 -0
  7. {webhook_co-0.2.0 → webhook_co-0.2.1}/scripts/__init__.py +0 -0
  8. {webhook_co-0.2.0 → webhook_co-0.2.1}/scripts/generate_models.py +0 -0
  9. {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/__init__.py +0 -0
  10. {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_config.py +0 -0
  11. {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_errors.py +0 -0
  12. {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_generated/__init__.py +0 -0
  13. {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_generated/models.py +0 -0
  14. {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_http.py +0 -0
  15. {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_pagination.py +0 -0
  16. {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_query.py +0 -0
  17. {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_redaction.py +0 -0
  18. {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_retry.py +0 -0
  19. {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/py.typed +0 -0
  20. {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/__init__.py +0 -0
  21. {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_client.py +0 -0
  22. {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_config.py +0 -0
  23. {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_coverage_extra.py +0 -0
  24. {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_errors.py +0 -0
  25. {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_generated_drift.py +0 -0
  26. {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_http.py +0 -0
  27. {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_pagination.py +0 -0
  28. {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_query.py +0 -0
  29. {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_redaction.py +0 -0
  30. {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_retry.py +0 -0
  31. {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_spec_coverage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webhook-co
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: The official Python SDK for the webhook.co API — a typed client with retries, cursor pagination, idempotency, and secret redaction.
5
5
  Project-URL: Homepage, https://webhook.co
6
6
  Project-URL: Repository, https://github.com/webhook-co/webhook
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "webhook-co"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "The official Python SDK for the webhook.co API — a typed client with retries, cursor pagination, idempotency, and secret redaction."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """PACKAGED-ARTIFACT SMOKE TEST for the Python SDK.
3
+
4
+ The pytest suite imports from ``src/``. That proves the CODE works; it proves NOTHING about the WHEEL users
5
+ actually install — a missing package in ``[tool.hatch.build]``, an absent ``py.typed``, or a module that
6
+ never made it into the archive all ship green, because no test touches the built artifact.
7
+
8
+ So: build the real wheel, install it into a throwaway venv, import it as a user would, and drive it against
9
+ a live stub server. If the published artifact is unusable, this fails.
10
+
11
+ Run: python scripts/packaged_smoke.py (from sdks/python)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import pathlib
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+
23
+ HERE = pathlib.Path(__file__).resolve().parent
24
+ PKG = HERE.parent
25
+
26
+ # The client script that runs INSIDE the throwaway venv, against the installed wheel.
27
+ CLIENT = r"""
28
+ import json, threading, sys
29
+ from http.server import BaseHTTPRequestHandler, HTTPServer
30
+ from webhook_co import WebhookClient
31
+
32
+ # A faithful trigger event: the SDK validates responses through pydantic, so a thin stub is (correctly)
33
+ # rejected. That strictness is a feature — the SDK will not hand you a half-parsed event.
34
+ EVENT = {
35
+ "id": "11111111-1111-4111-8111-111111111111",
36
+ "orgId": "22222222-2222-4222-8222-222222222222",
37
+ "endpointId": "11111111-1111-4111-8111-111111111111",
38
+ "receivedAt": "2026-07-13T00:00:00Z",
39
+ "provider": "stripe",
40
+ "dedupKey": "dk",
41
+ "dedupStrategy": "content_hash",
42
+ "verified": True,
43
+ "vouched": True,
44
+ }
45
+ CALLS = []
46
+
47
+ class H(BaseHTTPRequestHandler):
48
+ def log_message(self, *a):
49
+ pass
50
+
51
+ def _send(self, obj):
52
+ b = json.dumps(obj).encode()
53
+ self.send_response(200)
54
+ self.send_header("content-type", "application/json")
55
+ self.send_header("content-length", str(len(b)))
56
+ self.end_headers()
57
+ self.wfile.write(b)
58
+
59
+ def _handle(self):
60
+ n = int(self.headers.get("content-length") or 0)
61
+ if n:
62
+ self.rfile.read(n)
63
+ CALLS.append({"method": self.command, "path": self.path, "auth": self.headers.get("authorization")})
64
+ path, _, qs = self.path.partition("?")
65
+ q = dict(p.split("=", 1) for p in qs.split("&") if "=" in p)
66
+ if path == "/v1/endpoints" and self.command == "POST":
67
+ return self._send({
68
+ "id": "11111111-1111-4111-8111-111111111111",
69
+ "orgId": "22222222-2222-4222-8222-222222222222",
70
+ "name": "orders", "paused": False, "createdAt": "2026-07-13T00:00:00Z",
71
+ "dedupConfig": None, "ingestUrl": "https://wbhk.my/whep_SEALED",
72
+ })
73
+ if path.endswith("/reveal-ingest-url"):
74
+ return self._send({"ingestUrl": "https://wbhk.my/whep_SEALED"})
75
+ if path == "/v1/usage":
76
+ return self._send({
77
+ "periodStart": "2026-07-01T00:00:00Z", "periodEnd": None, "capKind": "lifetime",
78
+ "events": 42, "eventCap": 5000, "pausePolicy": "pause", "paused": False,
79
+ })
80
+ if path.endswith("/wait"):
81
+ # Faithful to the server: a page WITH events returns a fresh cursor; an EMPTY page ECHOES the
82
+ # cursor you sent; null only when you sent none. So null means "from the oldest", not "caught up".
83
+ cur = q.get("cursor")
84
+ if cur is None:
85
+ return self._send({"events": [EVENT], "nextCursor": "c1", "caughtUp": False})
86
+ return self._send({"events": [], "nextCursor": cur, "caughtUp": True})
87
+ if path == "/v1/triggers" and self.command == "POST":
88
+ return self._send({
89
+ "id": "11111111-1111-4111-8111-111111111111",
90
+ "orgId": "22222222-2222-4222-8222-222222222222",
91
+ "endpointId": "11111111-1111-4111-8111-111111111111",
92
+ "name": None, "createdAt": "2026-07-13T00:00:00Z", "revokedAt": None,
93
+ })
94
+ self._send({})
95
+
96
+ do_GET = do_POST = do_PATCH = do_DELETE = _handle
97
+
98
+ srv = HTTPServer(("127.0.0.1", 0), H)
99
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
100
+ client = WebhookClient(api_key="whk_smoke_test_key_abcdefgh", base_url=f"http://127.0.0.1:{srv.server_address[1]}")
101
+
102
+ fails = []
103
+ def check(name, cond, extra=""):
104
+ print(f" {'PASS' if cond else 'FAIL'} {name}" + ("" if cond else f" <- {extra}"))
105
+ if not cond:
106
+ fails.append(name)
107
+
108
+ import webhook_co
109
+ check("the installed wheel imports and exposes WebhookClient", callable(WebhookClient))
110
+
111
+ ep = client.endpoints.create(name="orders")
112
+ check("endpoints.create returns the ingest url", str(ep.ingest_url) == "https://wbhk.my/whep_SEALED", ep.ingest_url)
113
+
114
+ rev = client.endpoints.reveal_ingest_url(ep.id)
115
+ check("endpoints.reveal_ingest_url recovers the SAME url (no rotation)", str(rev.ingest_url) == str(ep.ingest_url))
116
+
117
+ check("usage.get works", client.usage.get().events == 42)
118
+
119
+ trig = client.triggers.create(endpoint_id=ep.id)
120
+ cursor, seen = None, []
121
+ for _ in range(3):
122
+ page = client.triggers.wait(trig.id, cursor=cursor)
123
+ seen.extend(page.events)
124
+ cursor = page.next_cursor
125
+ check("triggers.wait drains events and terminates", len(seen) == 1, len(seen))
126
+
127
+ waits = [c for c in CALLS if "/wait" in c["path"]]
128
+ check("triggers.wait never sends a literal null cursor",
129
+ not any("cursor=None" in c["path"] or "cursor=null" in c["path"] for c in waits),
130
+ [c["path"] for c in waits])
131
+
132
+ client.triggers.wait(trig.id, include_body=False)
133
+ last = [c for c in CALLS if "/wait" in c["path"]][-1]
134
+ check("include_body=False serialises lowercase (the API silently ignores 'False')",
135
+ "includeBody=false" in last["path"], last["path"])
136
+
137
+ reveal = next(c for c in CALLS if "reveal-ingest-url" in c["path"])
138
+ check("reveal is a POST carrying the bearer",
139
+ reveal["method"] == "POST" and reveal["auth"] == "Bearer whk_smoke_test_key_abcdefgh")
140
+
141
+ srv.shutdown()
142
+ print("\npackaged Python SDK: ALL PASS" if not fails else f"\npackaged Python SDK: {len(fails)} FAILURE(S)")
143
+ sys.exit(1 if fails else 0)
144
+ """
145
+
146
+
147
+ def main() -> int:
148
+ scratch = pathlib.Path(tempfile.mkdtemp(prefix="py-sdk-smoke-"))
149
+ try:
150
+ print("building the real wheel …")
151
+ subprocess.run(
152
+ [
153
+ sys.executable,
154
+ "-m",
155
+ "pip",
156
+ "wheel",
157
+ "--no-deps",
158
+ "-w",
159
+ str(scratch),
160
+ str(PKG),
161
+ ],
162
+ check=True,
163
+ stdout=subprocess.DEVNULL,
164
+ )
165
+ wheel = next(scratch.glob("webhook_co-*.whl"))
166
+
167
+ print(f"installing {wheel.name} into a throwaway venv …")
168
+ venv = scratch / "venv"
169
+ subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True)
170
+ py = venv / "bin" / "python"
171
+ subprocess.run([str(py), "-m", "pip", "-q", "install", str(wheel)], check=True)
172
+
173
+ script = scratch / "smoke.py"
174
+ script.write_text(CLIENT)
175
+ # cwd=scratch so `webhook_co` can only resolve from the INSTALLED WHEEL, never from ../src.
176
+ return subprocess.run([str(py), str(script)], cwd=scratch).returncode
177
+ finally:
178
+ shutil.rmtree(scratch, ignore_errors=True)
179
+
180
+
181
+ if __name__ == "__main__":
182
+ raise SystemExit(main())
@@ -619,15 +619,19 @@ class _TriggersResource:
619
619
  held connection and no special timeout handling. Drain a backlog by re-calling promptly while
620
620
  ``caught_up`` is False, then poll on your own cadence once caught up.
621
621
 
622
- ``cursor`` accepts None so the ``next_cursor`` of a caught-up page round-trips straight back in::
622
+ ``cursor`` accepts None so a None ``next_cursor`` round-trips straight back in. Read the semantics
623
+ carefully: ``next_cursor`` is None ONLY when you sent no cursor and there were no events — an empty
624
+ page ECHOES the cursor you sent. So None means "start from the oldest retained event", NOT "caught
625
+ up". ``caught_up`` is the caught-up signal; never infer it from a None cursor. (Passing None back is
626
+ safe: you re-read from the oldest, which is at-least-once by design — dedup on the event id.)::
623
627
 
624
628
  cursor = None
625
629
  while True:
626
630
  page = client.triggers.wait(trigger_id, cursor=cursor)
627
631
  for event in page.events:
628
632
  handle(event)
629
- cursor = page.next_cursor # None when caught up — safe to pass right back
630
- if page.caught_up:
633
+ cursor = page.next_cursor # feed straight back in
634
+ if page.caught_up: # <- the caught-up signal
631
635
  time.sleep(1)
632
636
 
633
637
  At-least-once: a crash before you persist ``next_cursor`` re-reads, never loses — dedup on the event
File without changes
File without changes
File without changes