webhook-co 0.2.0__tar.gz → 0.3.0__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.3.0}/PKG-INFO +1 -1
- {webhook_co-0.2.0 → webhook_co-0.3.0}/pyproject.toml +1 -1
- webhook_co-0.3.0/scripts/packaged_smoke.py +182 -0
- webhook_co-0.3.0/src/webhook_co/_advisory.py +133 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_http.py +15 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/client.py +19 -3
- webhook_co-0.3.0/tests/test_advisory.py +131 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/.gitignore +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/README.md +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/scripts/__init__.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/scripts/generate_models.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/__init__.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_config.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_errors.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_generated/__init__.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_generated/models.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_pagination.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_query.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_redaction.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_retry.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/py.typed +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/__init__.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_client.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_config.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_coverage_extra.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_errors.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_generated_drift.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_http.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_pagination.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_query.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_redaction.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_retry.py +0 -0
- {webhook_co-0.2.0 → webhook_co-0.3.0}/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.
|
|
3
|
+
Version: 0.3.0
|
|
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.
|
|
7
|
+
version = "0.3.0"
|
|
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())
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""CLIENT VERSION ADVISORIES (receiving end) + the versioned User-Agent.
|
|
2
|
+
|
|
3
|
+
The SDK identifies itself in its User-Agent; when this version is behind, the API answers with an
|
|
4
|
+
``x-webhook-advisory`` header ON A RESPONSE THE CALLER ALREADY ASKED FOR. So the SDK never polls PyPI,
|
|
5
|
+
never makes an unsolicited network call from inside your Lambda, and works fine offline — you simply hear
|
|
6
|
+
nothing.
|
|
7
|
+
|
|
8
|
+
House rules for surfacing it, because a library that nags is a library people vendor to shut it up:
|
|
9
|
+
- report ONCE per client, not once per request;
|
|
10
|
+
- prefer the caller's own handler; only fall back to a single ``warnings`` emission;
|
|
11
|
+
- be silenceable, and never break a request if any of this goes wrong.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import platform
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import warnings
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Callable
|
|
22
|
+
|
|
23
|
+
ADVISORY_HEADER = "x-webhook-advisory"
|
|
24
|
+
DEPRECATION_HEADER = "deprecation"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _sdk_version() -> str:
|
|
28
|
+
"""The installed version, read from package metadata.
|
|
29
|
+
|
|
30
|
+
Unlike the TS and Go SDKs, Python does not need a stamped constant: the wheel's own metadata IS the
|
|
31
|
+
version, so the User-Agent physically cannot disagree with what is installed. Falls back to 0.0.0 when
|
|
32
|
+
running from a source tree with no metadata (a dev checkout).
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
from importlib.metadata import version
|
|
36
|
+
|
|
37
|
+
return version("webhook-co")
|
|
38
|
+
except (
|
|
39
|
+
Exception
|
|
40
|
+
): # pragma: no cover - defensive: never let identifying ourselves break a request
|
|
41
|
+
return "0.0.0"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
SDK_VERSION = _sdk_version()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def user_agent() -> str:
|
|
48
|
+
"""``webhook-co-python/0.2.1 (python/3.12.3; darwin)`` — client, version, runtime. Nothing about you."""
|
|
49
|
+
try:
|
|
50
|
+
py = platform.python_version()
|
|
51
|
+
os_name = sys.platform
|
|
52
|
+
return f"webhook-co-python/{SDK_VERSION} (python/{py}; {os_name})"
|
|
53
|
+
except Exception: # pragma: no cover
|
|
54
|
+
return f"webhook-co-python/{SDK_VERSION}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class WebhookAdvisory:
|
|
59
|
+
"""A version advisory from the server. ``deprecated`` = BELOW the supported floor: broken, not just old."""
|
|
60
|
+
|
|
61
|
+
deprecated: bool
|
|
62
|
+
current: str
|
|
63
|
+
latest: str
|
|
64
|
+
message: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_KIND = re.compile(
|
|
68
|
+
r"^(update-available|deprecated);\s*current=([\w.+-]+);\s*latest=([\w.+-]+)$"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_advisory(
|
|
73
|
+
header: str | None, deprecation: str | None
|
|
74
|
+
) -> WebhookAdvisory | None:
|
|
75
|
+
"""Parse the advisory header. Returns None for absent OR malformed input.
|
|
76
|
+
|
|
77
|
+
The server is not this SDK's parser: a garbled or hostile header must never raise inside a caller's
|
|
78
|
+
request path. The worst it can do is say nothing.
|
|
79
|
+
"""
|
|
80
|
+
if not header:
|
|
81
|
+
return None
|
|
82
|
+
m = _KIND.match(header.strip())
|
|
83
|
+
if not m:
|
|
84
|
+
return None
|
|
85
|
+
kind, current, latest = m.group(1), m.group(2), m.group(3)
|
|
86
|
+
deprecated = kind == "deprecated" or deprecation == "true"
|
|
87
|
+
if deprecated:
|
|
88
|
+
message = (
|
|
89
|
+
f"webhook.co: this SDK version ({current}) is no longer supported and may misbehave. "
|
|
90
|
+
f"Upgrade to {latest}: pip install --upgrade webhook-co"
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
message = (
|
|
94
|
+
f"webhook.co: a newer SDK is available ({current} -> {latest}). "
|
|
95
|
+
f"Upgrade with: pip install --upgrade webhook-co"
|
|
96
|
+
)
|
|
97
|
+
return WebhookAdvisory(
|
|
98
|
+
deprecated=deprecated, current=current, latest=latest, message=message
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def make_advisory_reporter(
|
|
103
|
+
on_advisory: Callable[[WebhookAdvisory], None] | None = None,
|
|
104
|
+
*,
|
|
105
|
+
silent: bool = False,
|
|
106
|
+
warn: Callable[[str], None] | None = None,
|
|
107
|
+
) -> Callable[[str | None, str | None], None]:
|
|
108
|
+
"""Build the per-client reporter: fires AT MOST ONCE, swallows a caller handler's exception.
|
|
109
|
+
|
|
110
|
+
A per-request nag would be a bug, not a feature. And if the caller's handler raises, that is their bug —
|
|
111
|
+
it must not surface as a failed API call.
|
|
112
|
+
"""
|
|
113
|
+
state = {"reported": False}
|
|
114
|
+
emit = warn or (lambda message: warnings.warn(message, UserWarning, stacklevel=2))
|
|
115
|
+
|
|
116
|
+
def report(header: str | None, deprecation: str | None) -> None:
|
|
117
|
+
if state["reported"] or silent:
|
|
118
|
+
return
|
|
119
|
+
advisory = parse_advisory(header, deprecation)
|
|
120
|
+
if advisory is None:
|
|
121
|
+
return
|
|
122
|
+
state["reported"] = True
|
|
123
|
+
try:
|
|
124
|
+
if on_advisory is not None:
|
|
125
|
+
on_advisory(advisory)
|
|
126
|
+
else:
|
|
127
|
+
emit(advisory.message)
|
|
128
|
+
except (
|
|
129
|
+
Exception
|
|
130
|
+
): # noqa: BLE001 - the caller's logging bug must not fail their request
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
return report
|
|
@@ -18,6 +18,7 @@ from typing import Any
|
|
|
18
18
|
|
|
19
19
|
import httpx
|
|
20
20
|
|
|
21
|
+
from ._advisory import ADVISORY_HEADER, DEPRECATION_HEADER, user_agent
|
|
21
22
|
from ._errors import (
|
|
22
23
|
WebhookErrorCode,
|
|
23
24
|
WebhookUnexpectedResponseError,
|
|
@@ -68,10 +69,12 @@ class HttpClient:
|
|
|
68
69
|
on_debug: Callable[[str], None] | None = None,
|
|
69
70
|
sleep: Callable[[float], None] | None = None,
|
|
70
71
|
rand: Callable[[], float] | None = None,
|
|
72
|
+
report_advisory: Callable[[str | None, str | None], None] | None = None,
|
|
71
73
|
) -> None:
|
|
72
74
|
self._base_url = base_url
|
|
73
75
|
self._api_key = api_key
|
|
74
76
|
self._client = http_client
|
|
77
|
+
self._report_advisory = report_advisory
|
|
75
78
|
self._max_retries = DEFAULT_MAX_RETRIES if max_retries is None else max_retries
|
|
76
79
|
self._timeout_s = timeout_s
|
|
77
80
|
self._refresh_auth = refresh_auth
|
|
@@ -108,6 +111,10 @@ class HttpClient:
|
|
|
108
111
|
headers = {
|
|
109
112
|
"authorization": f"Bearer {bearer}",
|
|
110
113
|
"accept": "application/json",
|
|
114
|
+
# Identify the client + version. This is what lets the server answer with a version
|
|
115
|
+
# advisory on a response you already asked for, instead of the SDK polling PyPI behind
|
|
116
|
+
# your back.
|
|
117
|
+
"user-agent": user_agent(),
|
|
111
118
|
}
|
|
112
119
|
content: bytes | None = None
|
|
113
120
|
if body is not None:
|
|
@@ -137,6 +144,14 @@ class HttpClient:
|
|
|
137
144
|
continue
|
|
138
145
|
raise error_from_transport(self._base_url) from None
|
|
139
146
|
|
|
147
|
+
# The server rides a version advisory on a response we already asked for. Surface it (at most
|
|
148
|
+
# once) regardless of status: a client too old to work is precisely the one seeing errors.
|
|
149
|
+
if self._report_advisory is not None:
|
|
150
|
+
self._report_advisory(
|
|
151
|
+
res.headers.get(ADVISORY_HEADER),
|
|
152
|
+
res.headers.get(DEPRECATION_HEADER),
|
|
153
|
+
)
|
|
154
|
+
|
|
140
155
|
if res.is_success:
|
|
141
156
|
return self._parse_success(res)
|
|
142
157
|
|
|
@@ -19,6 +19,7 @@ from urllib.parse import quote
|
|
|
19
19
|
import httpx
|
|
20
20
|
import pydantic
|
|
21
21
|
|
|
22
|
+
from ._advisory import WebhookAdvisory, make_advisory_reporter
|
|
22
23
|
from ._config import resolve_base_url
|
|
23
24
|
from ._errors import WebhookConfigError, WebhookUnexpectedResponseError
|
|
24
25
|
from ._generated import models as m
|
|
@@ -619,15 +620,19 @@ class _TriggersResource:
|
|
|
619
620
|
held connection and no special timeout handling. Drain a backlog by re-calling promptly while
|
|
620
621
|
``caught_up`` is False, then poll on your own cadence once caught up.
|
|
621
622
|
|
|
622
|
-
``cursor`` accepts None so
|
|
623
|
+
``cursor`` accepts None so a None ``next_cursor`` round-trips straight back in. Read the semantics
|
|
624
|
+
carefully: ``next_cursor`` is None ONLY when you sent no cursor and there were no events — an empty
|
|
625
|
+
page ECHOES the cursor you sent. So None means "start from the oldest retained event", NOT "caught
|
|
626
|
+
up". ``caught_up`` is the caught-up signal; never infer it from a None cursor. (Passing None back is
|
|
627
|
+
safe: you re-read from the oldest, which is at-least-once by design — dedup on the event id.)::
|
|
623
628
|
|
|
624
629
|
cursor = None
|
|
625
630
|
while True:
|
|
626
631
|
page = client.triggers.wait(trigger_id, cursor=cursor)
|
|
627
632
|
for event in page.events:
|
|
628
633
|
handle(event)
|
|
629
|
-
cursor = page.next_cursor #
|
|
630
|
-
if page.caught_up:
|
|
634
|
+
cursor = page.next_cursor # feed straight back in
|
|
635
|
+
if page.caught_up: # <- the caught-up signal
|
|
631
636
|
time.sleep(1)
|
|
632
637
|
|
|
633
638
|
At-least-once: a crash before you persist ``next_cursor`` re-reads, never loses — dedup on the event
|
|
@@ -670,6 +675,11 @@ class WebhookClient:
|
|
|
670
675
|
default 30. Not a single total-wall-clock deadline.
|
|
671
676
|
refresh_auth: Hook to swap in a rotated bearer on a 401 (OAuth flows).
|
|
672
677
|
on_debug: Optional sink for redacted, single-line diagnostics (never the raw key).
|
|
678
|
+
on_advisory: Called AT MOST ONCE if the server reports this SDK version is behind. The advisory
|
|
679
|
+
rides an ``x-webhook-advisory`` header on a response you already made — the SDK never polls
|
|
680
|
+
PyPI on your behalf. Give a handler and it is yours to log; give none and the SDK emits a
|
|
681
|
+
single ``UserWarning``.
|
|
682
|
+
silence_advisories: Suppress version advisories entirely, including the warning.
|
|
673
683
|
"""
|
|
674
684
|
|
|
675
685
|
def __init__(
|
|
@@ -682,6 +692,8 @@ class WebhookClient:
|
|
|
682
692
|
timeout_s: float | None = None,
|
|
683
693
|
refresh_auth: Callable[[], str | None] | None = None,
|
|
684
694
|
on_debug: Callable[[str], None] | None = None,
|
|
695
|
+
on_advisory: Callable[[WebhookAdvisory], None] | None = None,
|
|
696
|
+
silence_advisories: bool = False,
|
|
685
697
|
sleep: Callable[[float], None] | None = None,
|
|
686
698
|
rand: Callable[[], float] | None = None,
|
|
687
699
|
) -> None:
|
|
@@ -704,6 +716,10 @@ class WebhookClient:
|
|
|
704
716
|
timeout_s=timeout_s if timeout_s is not None else DEFAULT_TIMEOUT_S,
|
|
705
717
|
refresh_auth=refresh_auth,
|
|
706
718
|
on_debug=on_debug,
|
|
719
|
+
# One reporter per client: the advisory fires at most once, not once per request.
|
|
720
|
+
report_advisory=make_advisory_reporter(
|
|
721
|
+
on_advisory, silent=silence_advisories
|
|
722
|
+
),
|
|
707
723
|
sleep=sleep,
|
|
708
724
|
rand=rand,
|
|
709
725
|
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The server-driven version advisory: the SDK identifies itself, the server answers on a response the
|
|
2
|
+
caller already made. No PyPI polling from inside someone's Lambda, no extra request."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from webhook_co import WebhookClient
|
|
10
|
+
from webhook_co._advisory import (
|
|
11
|
+
make_advisory_reporter,
|
|
12
|
+
parse_advisory,
|
|
13
|
+
user_agent,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestUserAgent:
|
|
18
|
+
def test_identifies_client_and_version(self):
|
|
19
|
+
ua = user_agent()
|
|
20
|
+
assert ua.startswith("webhook-co-python/")
|
|
21
|
+
assert "python/" in ua
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestParseAdvisory:
|
|
25
|
+
def test_parses_an_update(self):
|
|
26
|
+
a = parse_advisory("update-available; current=0.2.0; latest=0.3.0", None)
|
|
27
|
+
assert a is not None and a.current == "0.2.0" and a.latest == "0.3.0"
|
|
28
|
+
assert a.deprecated is False
|
|
29
|
+
|
|
30
|
+
def test_deprecation_is_louder_than_an_update(self):
|
|
31
|
+
a = parse_advisory("deprecated; current=0.1.0; latest=0.3.0", "true")
|
|
32
|
+
assert a is not None and a.deprecated is True
|
|
33
|
+
assert "no longer supported" in a.message
|
|
34
|
+
|
|
35
|
+
def test_absent_or_malformed_returns_none_and_never_raises(self):
|
|
36
|
+
# The server is not this SDK's parser. A garbled header must never raise in a caller's request path.
|
|
37
|
+
for bad in [
|
|
38
|
+
None,
|
|
39
|
+
"",
|
|
40
|
+
"garbage",
|
|
41
|
+
"update-available",
|
|
42
|
+
"update-available; current=; latest=",
|
|
43
|
+
]:
|
|
44
|
+
assert parse_advisory(bad, None) is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestReporter:
|
|
48
|
+
def test_reports_once_however_many_requests(self):
|
|
49
|
+
seen = []
|
|
50
|
+
report = make_advisory_reporter(seen.append)
|
|
51
|
+
for _ in range(5):
|
|
52
|
+
report("update-available; current=0.2.0; latest=0.3.0", None)
|
|
53
|
+
assert len(seen) == 1 # a per-request nag is a bug, not a feature
|
|
54
|
+
|
|
55
|
+
def test_falls_back_to_a_single_warning_with_no_handler(self):
|
|
56
|
+
warned = []
|
|
57
|
+
report = make_advisory_reporter(None, warn=warned.append)
|
|
58
|
+
report("update-available; current=0.2.0; latest=0.3.0", None)
|
|
59
|
+
report("update-available; current=0.2.0; latest=0.3.0", None)
|
|
60
|
+
assert len(warned) == 1 and "0.2.0 -> 0.3.0" in warned[0]
|
|
61
|
+
|
|
62
|
+
def test_silent_says_nothing_at_all(self):
|
|
63
|
+
warned = []
|
|
64
|
+
report = make_advisory_reporter(None, silent=True, warn=warned.append)
|
|
65
|
+
report("deprecated; current=0.1.0; latest=0.3.0", "true")
|
|
66
|
+
assert warned == []
|
|
67
|
+
|
|
68
|
+
def test_a_throwing_handler_never_breaks_the_request(self):
|
|
69
|
+
def boom(_a):
|
|
70
|
+
raise RuntimeError("caller's logging bug")
|
|
71
|
+
|
|
72
|
+
report = make_advisory_reporter(boom)
|
|
73
|
+
report("update-available; current=0.2.0; latest=0.3.0", None) # must not raise
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestEndToEnd:
|
|
77
|
+
"""Through the real client: we send the UA, the server rides an advisory, we surface it ONCE."""
|
|
78
|
+
|
|
79
|
+
# A valid whoami body: the SDK validates responses, so a thin stub is (correctly) rejected.
|
|
80
|
+
WHOAMI = {
|
|
81
|
+
"orgId": "22222222-2222-4222-8222-222222222222",
|
|
82
|
+
"userId": None,
|
|
83
|
+
"scopes": [],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def _client(self, headers, seen, **kw):
|
|
87
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
88
|
+
seen.append(request)
|
|
89
|
+
return httpx.Response(200, json=self.WHOAMI, headers=headers)
|
|
90
|
+
|
|
91
|
+
return WebhookClient(
|
|
92
|
+
api_key="whk_advisory_test_key_abc",
|
|
93
|
+
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
|
94
|
+
**kw,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def test_sends_a_versioned_user_agent(self):
|
|
98
|
+
seen = []
|
|
99
|
+
client = self._client({}, seen)
|
|
100
|
+
client.whoami()
|
|
101
|
+
assert seen[0].headers["user-agent"].startswith("webhook-co-python/")
|
|
102
|
+
|
|
103
|
+
def test_surfaces_the_advisory_exactly_once(self):
|
|
104
|
+
got, seen = [], []
|
|
105
|
+
client = self._client(
|
|
106
|
+
{"x-webhook-advisory": "update-available; current=0.0.0; latest=9.9.9"},
|
|
107
|
+
seen,
|
|
108
|
+
on_advisory=got.append,
|
|
109
|
+
)
|
|
110
|
+
client.whoami()
|
|
111
|
+
client.whoami()
|
|
112
|
+
client.whoami()
|
|
113
|
+
assert len(got) == 1 # once per client, not once per request
|
|
114
|
+
assert got[0].latest == "9.9.9"
|
|
115
|
+
|
|
116
|
+
def test_silent_when_the_server_sends_no_advisory(self):
|
|
117
|
+
got, seen = [], []
|
|
118
|
+
client = self._client({}, seen, on_advisory=got.append)
|
|
119
|
+
client.whoami()
|
|
120
|
+
assert got == []
|
|
121
|
+
|
|
122
|
+
def test_can_be_silenced(self):
|
|
123
|
+
got, seen = [], []
|
|
124
|
+
client = self._client(
|
|
125
|
+
{"x-webhook-advisory": "update-available; current=0.0.0; latest=9.9.9"},
|
|
126
|
+
seen,
|
|
127
|
+
on_advisory=got.append,
|
|
128
|
+
silence_advisories=True,
|
|
129
|
+
)
|
|
130
|
+
client.whoami()
|
|
131
|
+
assert got == []
|
|
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
|