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.
Files changed (33) hide show
  1. {webhook_co-0.2.0 → webhook_co-0.3.0}/PKG-INFO +1 -1
  2. {webhook_co-0.2.0 → webhook_co-0.3.0}/pyproject.toml +1 -1
  3. webhook_co-0.3.0/scripts/packaged_smoke.py +182 -0
  4. webhook_co-0.3.0/src/webhook_co/_advisory.py +133 -0
  5. {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_http.py +15 -0
  6. {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/client.py +19 -3
  7. webhook_co-0.3.0/tests/test_advisory.py +131 -0
  8. {webhook_co-0.2.0 → webhook_co-0.3.0}/.gitignore +0 -0
  9. {webhook_co-0.2.0 → webhook_co-0.3.0}/README.md +0 -0
  10. {webhook_co-0.2.0 → webhook_co-0.3.0}/scripts/__init__.py +0 -0
  11. {webhook_co-0.2.0 → webhook_co-0.3.0}/scripts/generate_models.py +0 -0
  12. {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/__init__.py +0 -0
  13. {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_config.py +0 -0
  14. {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_errors.py +0 -0
  15. {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_generated/__init__.py +0 -0
  16. {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_generated/models.py +0 -0
  17. {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_pagination.py +0 -0
  18. {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_query.py +0 -0
  19. {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_redaction.py +0 -0
  20. {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/_retry.py +0 -0
  21. {webhook_co-0.2.0 → webhook_co-0.3.0}/src/webhook_co/py.typed +0 -0
  22. {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/__init__.py +0 -0
  23. {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_client.py +0 -0
  24. {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_config.py +0 -0
  25. {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_coverage_extra.py +0 -0
  26. {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_errors.py +0 -0
  27. {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_generated_drift.py +0 -0
  28. {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_http.py +0 -0
  29. {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_pagination.py +0 -0
  30. {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_query.py +0 -0
  31. {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_redaction.py +0 -0
  32. {webhook_co-0.2.0 → webhook_co-0.3.0}/tests/test_retry.py +0 -0
  33. {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.2.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.2.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 the ``next_cursor`` of a caught-up page round-trips straight back in::
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 # None when caught up — safe to pass right back
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