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.
- {webhook_co-0.2.0 → webhook_co-0.2.1}/PKG-INFO +1 -1
- {webhook_co-0.2.0 → webhook_co-0.2.1}/pyproject.toml +1 -1
- webhook_co-0.2.1/scripts/packaged_smoke.py +182 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/client.py +7 -3
- {webhook_co-0.2.0 → webhook_co-0.2.1}/.gitignore +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/README.md +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/scripts/__init__.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/scripts/generate_models.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/__init__.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_config.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_errors.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_generated/__init__.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_generated/models.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_http.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_pagination.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_query.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_redaction.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/_retry.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/src/webhook_co/py.typed +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/__init__.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_client.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_config.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_coverage_extra.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_errors.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_generated_drift.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_http.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_pagination.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_query.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_redaction.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.2.1}/tests/test_retry.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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 #
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|