webhook-co 0.2.1__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.1 → webhook_co-0.3.0}/PKG-INFO +1 -1
  2. {webhook_co-0.2.1 → webhook_co-0.3.0}/pyproject.toml +1 -1
  3. webhook_co-0.3.0/src/webhook_co/_advisory.py +133 -0
  4. {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_http.py +15 -0
  5. {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/client.py +12 -0
  6. webhook_co-0.3.0/tests/test_advisory.py +131 -0
  7. {webhook_co-0.2.1 → webhook_co-0.3.0}/.gitignore +0 -0
  8. {webhook_co-0.2.1 → webhook_co-0.3.0}/README.md +0 -0
  9. {webhook_co-0.2.1 → webhook_co-0.3.0}/scripts/__init__.py +0 -0
  10. {webhook_co-0.2.1 → webhook_co-0.3.0}/scripts/generate_models.py +0 -0
  11. {webhook_co-0.2.1 → webhook_co-0.3.0}/scripts/packaged_smoke.py +0 -0
  12. {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/__init__.py +0 -0
  13. {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_config.py +0 -0
  14. {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_errors.py +0 -0
  15. {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_generated/__init__.py +0 -0
  16. {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_generated/models.py +0 -0
  17. {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_pagination.py +0 -0
  18. {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_query.py +0 -0
  19. {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_redaction.py +0 -0
  20. {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_retry.py +0 -0
  21. {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/py.typed +0 -0
  22. {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/__init__.py +0 -0
  23. {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_client.py +0 -0
  24. {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_config.py +0 -0
  25. {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_coverage_extra.py +0 -0
  26. {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_errors.py +0 -0
  27. {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_generated_drift.py +0 -0
  28. {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_http.py +0 -0
  29. {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_pagination.py +0 -0
  30. {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_query.py +0 -0
  31. {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_redaction.py +0 -0
  32. {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_retry.py +0 -0
  33. {webhook_co-0.2.1 → 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.1
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.1"
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,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
@@ -674,6 +675,11 @@ class WebhookClient:
674
675
  default 30. Not a single total-wall-clock deadline.
675
676
  refresh_auth: Hook to swap in a rotated bearer on a 401 (OAuth flows).
676
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.
677
683
  """
678
684
 
679
685
  def __init__(
@@ -686,6 +692,8 @@ class WebhookClient:
686
692
  timeout_s: float | None = None,
687
693
  refresh_auth: Callable[[], str | None] | None = None,
688
694
  on_debug: Callable[[str], None] | None = None,
695
+ on_advisory: Callable[[WebhookAdvisory], None] | None = None,
696
+ silence_advisories: bool = False,
689
697
  sleep: Callable[[float], None] | None = None,
690
698
  rand: Callable[[], float] | None = None,
691
699
  ) -> None:
@@ -708,6 +716,10 @@ class WebhookClient:
708
716
  timeout_s=timeout_s if timeout_s is not None else DEFAULT_TIMEOUT_S,
709
717
  refresh_auth=refresh_auth,
710
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
+ ),
711
723
  sleep=sleep,
712
724
  rand=rand,
713
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