hookbase 3.2.0__tar.gz → 4.0.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.
- {hookbase-3.2.0 → hookbase-4.0.0}/.github/workflows/python-publish.yml +3 -1
- {hookbase-3.2.0 → hookbase-4.0.0}/PKG-INFO +9 -3
- {hookbase-3.2.0 → hookbase-4.0.0}/README.md +8 -2
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/_client.py +39 -7
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/_pagination.py +210 -8
- hookbase-4.0.0/src/hookbase/_version.py +1 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/__init__.py +13 -1
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/applications.py +21 -0
- hookbase-4.0.0/src/hookbase/models/common.py +107 -0
- hookbase-4.0.0/src/hookbase/models/deliveries.py +136 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/destinations.py +24 -6
- hookbase-4.0.0/src/hookbase/models/dlq.py +142 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/endpoints.py +17 -0
- hookbase-4.0.0/src/hookbase/models/event_types.py +103 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/events.py +33 -10
- hookbase-4.0.0/src/hookbase/models/messages.py +168 -0
- hookbase-4.0.0/src/hookbase/models/subscriptions.py +78 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/transforms.py +20 -1
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/deliveries.py +28 -12
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/dlq.py +29 -10
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/endpoints.py +53 -4
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/event_types.py +6 -5
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/events.py +25 -17
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/message_log.py +63 -12
- hookbase-4.0.0/src/hookbase/resources/messages.py +115 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/subscriptions.py +14 -4
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/webhook.py +18 -1
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_destinations.py +9 -5
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_dlq.py +20 -7
- hookbase-4.0.0/tests/resources/test_inbound_contract.py +464 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_messages.py +6 -4
- hookbase-4.0.0/tests/resources/test_outbound_contract.py +736 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_sources.py +42 -4
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/test_webhook.py +60 -0
- hookbase-3.2.0/src/hookbase/_version.py +0 -1
- hookbase-3.2.0/src/hookbase/models/common.py +0 -42
- hookbase-3.2.0/src/hookbase/models/deliveries.py +0 -55
- hookbase-3.2.0/src/hookbase/models/dlq.py +0 -60
- hookbase-3.2.0/src/hookbase/models/event_types.py +0 -37
- hookbase-3.2.0/src/hookbase/models/messages.py +0 -73
- hookbase-3.2.0/src/hookbase/models/subscriptions.py +0 -22
- hookbase-3.2.0/src/hookbase/resources/messages.py +0 -70
- {hookbase-3.2.0 → hookbase-4.0.0}/.gitignore +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/LICENSE +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/examples/async_usage.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/examples/basic_inbound.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/examples/error_handling.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/examples/send_webhooks.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/examples/verify_webhooks.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/pyproject.toml +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/__init__.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/_constants.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/client.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/errors.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/_base.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/_wire.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/analytics.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/api_keys.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/catalog.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/cron_jobs.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/filters.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/organizations.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/portal_tokens.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/routes.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/schemas.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/sources.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/tunnels.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/py.typed +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/__init__.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/_base.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/analytics.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/api_keys.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/applications.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/cron_jobs.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/destinations.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/filters.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/organizations.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/portal_tokens.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/providers.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/routes.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/schemas.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/sources.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/transforms.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/tunnels.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/__init__.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/conftest.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/__init__.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_applications.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_cron_jobs.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_endpoints.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_source_contract.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_source_fields.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/test_client.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/test_errors.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/test_pagination.py +0 -0
- {hookbase-3.2.0 → hookbase-4.0.0}/tests/test_slug_contract.py +0 -0
|
@@ -13,7 +13,9 @@ permissions:
|
|
|
13
13
|
|
|
14
14
|
jobs:
|
|
15
15
|
test:
|
|
16
|
-
|
|
16
|
+
# ubuntu-latest (26.04) has no Python 3.9 build for setup-python; 24.04 does, and 3.9 is
|
|
17
|
+
# still in requires-python.
|
|
18
|
+
runs-on: ubuntu-24.04
|
|
17
19
|
strategy:
|
|
18
20
|
matrix:
|
|
19
21
|
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hookbase
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.0
|
|
4
4
|
Summary: Official Python SDK for the Hookbase webhook management platform
|
|
5
5
|
Project-URL: Homepage, https://hookbase.app
|
|
6
6
|
Project-URL: Documentation, https://www.hookbase.app/docs/send/sdk
|
|
@@ -137,7 +137,7 @@ client.deliveries.bulk_replay(["del_1", "del_2"])
|
|
|
137
137
|
|
|
138
138
|
# Transforms & Filters
|
|
139
139
|
client.transforms.create({"name": "Extract", "transformType": "jsonata", "code": "$.data"})
|
|
140
|
-
client.transforms.test("
|
|
140
|
+
client.transforms.test(code="$.data", payload={"data": {"key": "value"}}) # .result
|
|
141
141
|
client.filters.test([{"field": "$.type", "operator": "eq", "value": "order"}], payload={...})
|
|
142
142
|
|
|
143
143
|
# Schemas
|
|
@@ -213,10 +213,16 @@ for source in page.auto_paging_iter():
|
|
|
213
213
|
while page.has_more:
|
|
214
214
|
page = page.next_page()
|
|
215
215
|
|
|
216
|
-
# Cursor-based (applications, endpoints, subscriptions, etc.)
|
|
216
|
+
# Cursor-based (inbound events, applications, endpoints, subscriptions, etc.)
|
|
217
217
|
page = client.outbound.applications.list(limit=50)
|
|
218
218
|
for app in page.auto_paging_iter():
|
|
219
219
|
print(app.name)
|
|
220
|
+
for event in client.events.list(limit=100).auto_paging_iter():
|
|
221
|
+
print(event.id)
|
|
222
|
+
|
|
223
|
+
# Limit/offset with no total (deliveries): pages until one comes back short
|
|
224
|
+
for delivery in client.deliveries.list(status="failed", limit=100).auto_paging_iter():
|
|
225
|
+
print(delivery.id, delivery.response_status)
|
|
220
226
|
```
|
|
221
227
|
|
|
222
228
|
### Webhook Verification
|
|
@@ -102,7 +102,7 @@ client.deliveries.bulk_replay(["del_1", "del_2"])
|
|
|
102
102
|
|
|
103
103
|
# Transforms & Filters
|
|
104
104
|
client.transforms.create({"name": "Extract", "transformType": "jsonata", "code": "$.data"})
|
|
105
|
-
client.transforms.test("
|
|
105
|
+
client.transforms.test(code="$.data", payload={"data": {"key": "value"}}) # .result
|
|
106
106
|
client.filters.test([{"field": "$.type", "operator": "eq", "value": "order"}], payload={...})
|
|
107
107
|
|
|
108
108
|
# Schemas
|
|
@@ -178,10 +178,16 @@ for source in page.auto_paging_iter():
|
|
|
178
178
|
while page.has_more:
|
|
179
179
|
page = page.next_page()
|
|
180
180
|
|
|
181
|
-
# Cursor-based (applications, endpoints, subscriptions, etc.)
|
|
181
|
+
# Cursor-based (inbound events, applications, endpoints, subscriptions, etc.)
|
|
182
182
|
page = client.outbound.applications.list(limit=50)
|
|
183
183
|
for app in page.auto_paging_iter():
|
|
184
184
|
print(app.name)
|
|
185
|
+
for event in client.events.list(limit=100).auto_paging_iter():
|
|
186
|
+
print(event.id)
|
|
187
|
+
|
|
188
|
+
# Limit/offset with no total (deliveries): pages until one comes back short
|
|
189
|
+
for delivery in client.deliveries.list(status="failed", limit=100).auto_paging_iter():
|
|
190
|
+
print(delivery.id, delivery.response_status)
|
|
185
191
|
```
|
|
186
192
|
|
|
187
193
|
### Webhook Verification
|
|
@@ -76,6 +76,31 @@ def _should_retry(status_code: int) -> bool:
|
|
|
76
76
|
return status_code in RETRY_STATUS_CODES
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
# A POST creates something or triggers work, so repeating one that may already have reached the
|
|
80
|
+
# server (a 5xx, a read timeout, a dropped connection) can do it twice. Only retry those when the
|
|
81
|
+
# request carries an idempotency key the server can deduplicate on. A 429 was rejected before any
|
|
82
|
+
# work was done, and a connect failure never reached the server, so those stay retryable.
|
|
83
|
+
_NON_IDEMPOTENT_METHODS = frozenset({"POST"})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _may_repeat(method: str, idempotency_key: str | None) -> bool:
|
|
87
|
+
"""Whether a request that may already have been processed is safe to send again."""
|
|
88
|
+
return method.upper() not in _NON_IDEMPOTENT_METHODS or bool(idempotency_key)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _never_sent(exc: httpx.HTTPError) -> bool:
|
|
92
|
+
"""Whether the transport failed before the request reached the server."""
|
|
93
|
+
return isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _decode_body(resp: httpx.Response) -> Any:
|
|
97
|
+
"""JSON for JSON responses; the text for anything else (e.g. a CSV export)."""
|
|
98
|
+
content_type = resp.headers.get("content-type", "")
|
|
99
|
+
if content_type and "json" not in content_type.lower():
|
|
100
|
+
return resp.text
|
|
101
|
+
return resp.json()
|
|
102
|
+
|
|
103
|
+
|
|
79
104
|
def _backoff(attempt: int) -> float:
|
|
80
105
|
base = min(1.0 * (2**attempt), 10.0)
|
|
81
106
|
jitter = random.random() # noqa: S311
|
|
@@ -126,6 +151,7 @@ class SyncTransport:
|
|
|
126
151
|
headers: dict[str, str] = {}
|
|
127
152
|
if idempotency_key:
|
|
128
153
|
headers["Idempotency-Key"] = idempotency_key
|
|
154
|
+
repeatable = _may_repeat(method, idempotency_key)
|
|
129
155
|
|
|
130
156
|
if self._debug:
|
|
131
157
|
logger.debug("[Hookbase] %s %s params=%s", method, path, cleaned)
|
|
@@ -143,13 +169,13 @@ class SyncTransport:
|
|
|
143
169
|
except httpx.TimeoutException as exc:
|
|
144
170
|
last_exc = TimeoutError(f"Request timed out after {self._timeout}s")
|
|
145
171
|
last_exc.__cause__ = exc
|
|
146
|
-
if attempt < self._max_retries:
|
|
172
|
+
if attempt < self._max_retries and (repeatable or _never_sent(exc)):
|
|
147
173
|
time.sleep(_backoff(attempt))
|
|
148
174
|
continue
|
|
149
175
|
raise last_exc from exc
|
|
150
176
|
except httpx.HTTPError as exc:
|
|
151
177
|
last_exc = NetworkError(str(exc), cause=exc)
|
|
152
|
-
if attempt < self._max_retries:
|
|
178
|
+
if attempt < self._max_retries and (repeatable or _never_sent(exc)):
|
|
153
179
|
time.sleep(_backoff(attempt))
|
|
154
180
|
continue
|
|
155
181
|
raise last_exc from exc
|
|
@@ -160,7 +186,7 @@ class SyncTransport:
|
|
|
160
186
|
return None
|
|
161
187
|
|
|
162
188
|
if resp.is_success:
|
|
163
|
-
return resp
|
|
189
|
+
return _decode_body(resp)
|
|
164
190
|
|
|
165
191
|
# Parse error body
|
|
166
192
|
try:
|
|
@@ -179,9 +205,12 @@ class SyncTransport:
|
|
|
179
205
|
except ValueError:
|
|
180
206
|
pass
|
|
181
207
|
|
|
182
|
-
# Don't retry client errors (except 429
|
|
208
|
+
# Don't retry client errors (except 429), nor a 5xx on a request
|
|
209
|
+
# that is not safe to repeat (see `_may_repeat`).
|
|
183
210
|
if not _should_retry(resp.status_code):
|
|
184
211
|
raise error
|
|
212
|
+
if not repeatable and not isinstance(error, RateLimitError):
|
|
213
|
+
raise error
|
|
185
214
|
|
|
186
215
|
last_exc = error
|
|
187
216
|
|
|
@@ -242,6 +271,7 @@ class AsyncTransport:
|
|
|
242
271
|
headers: dict[str, str] = {}
|
|
243
272
|
if idempotency_key:
|
|
244
273
|
headers["Idempotency-Key"] = idempotency_key
|
|
274
|
+
repeatable = _may_repeat(method, idempotency_key)
|
|
245
275
|
|
|
246
276
|
if self._debug:
|
|
247
277
|
logger.debug("[Hookbase] %s %s params=%s", method, path, cleaned)
|
|
@@ -259,13 +289,13 @@ class AsyncTransport:
|
|
|
259
289
|
except httpx.TimeoutException as exc:
|
|
260
290
|
last_exc = TimeoutError(f"Request timed out after {self._timeout}s")
|
|
261
291
|
last_exc.__cause__ = exc
|
|
262
|
-
if attempt < self._max_retries:
|
|
292
|
+
if attempt < self._max_retries and (repeatable or _never_sent(exc)):
|
|
263
293
|
await asyncio.sleep(_backoff(attempt))
|
|
264
294
|
continue
|
|
265
295
|
raise last_exc from exc
|
|
266
296
|
except httpx.HTTPError as exc:
|
|
267
297
|
last_exc = NetworkError(str(exc), cause=exc)
|
|
268
|
-
if attempt < self._max_retries:
|
|
298
|
+
if attempt < self._max_retries and (repeatable or _never_sent(exc)):
|
|
269
299
|
await asyncio.sleep(_backoff(attempt))
|
|
270
300
|
continue
|
|
271
301
|
raise last_exc from exc
|
|
@@ -276,7 +306,7 @@ class AsyncTransport:
|
|
|
276
306
|
return None
|
|
277
307
|
|
|
278
308
|
if resp.is_success:
|
|
279
|
-
return resp
|
|
309
|
+
return _decode_body(resp)
|
|
280
310
|
|
|
281
311
|
try:
|
|
282
312
|
body = resp.json()
|
|
@@ -295,6 +325,8 @@ class AsyncTransport:
|
|
|
295
325
|
|
|
296
326
|
if not _should_retry(resp.status_code):
|
|
297
327
|
raise error
|
|
328
|
+
if not repeatable and not isinstance(error, RateLimitError):
|
|
329
|
+
raise error
|
|
298
330
|
|
|
299
331
|
last_exc = error
|
|
300
332
|
|
|
@@ -140,6 +140,7 @@ class SyncCursorPage(Generic[T]):
|
|
|
140
140
|
path: str,
|
|
141
141
|
params: dict[str, Any],
|
|
142
142
|
model: type[T],
|
|
143
|
+
data_key: str | None = None,
|
|
143
144
|
) -> None:
|
|
144
145
|
self.data = data
|
|
145
146
|
self.has_more = has_more
|
|
@@ -148,6 +149,7 @@ class SyncCursorPage(Generic[T]):
|
|
|
148
149
|
self._path = path
|
|
149
150
|
self._params = params
|
|
150
151
|
self._model = model
|
|
152
|
+
self._data_key = data_key
|
|
151
153
|
|
|
152
154
|
def next_page(self) -> SyncCursorPage[T]:
|
|
153
155
|
return _fetch_cursor_page(
|
|
@@ -155,13 +157,14 @@ class SyncCursorPage(Generic[T]):
|
|
|
155
157
|
self._path,
|
|
156
158
|
{**self._params, "cursor": self.next_cursor},
|
|
157
159
|
self._model,
|
|
160
|
+
data_key=self._data_key,
|
|
158
161
|
)
|
|
159
162
|
|
|
160
163
|
def auto_paging_iter(self) -> Iterator[T]:
|
|
161
164
|
page = self
|
|
162
165
|
while True:
|
|
163
166
|
yield from page.data
|
|
164
|
-
if not page.has_more:
|
|
167
|
+
if not page.has_more or not page.next_cursor:
|
|
165
168
|
break
|
|
166
169
|
page = page.next_page()
|
|
167
170
|
|
|
@@ -189,6 +192,7 @@ class AsyncCursorPage(Generic[T]):
|
|
|
189
192
|
path: str,
|
|
190
193
|
params: dict[str, Any],
|
|
191
194
|
model: type[T],
|
|
195
|
+
data_key: str | None = None,
|
|
192
196
|
) -> None:
|
|
193
197
|
self.data = data
|
|
194
198
|
self.has_more = has_more
|
|
@@ -197,6 +201,7 @@ class AsyncCursorPage(Generic[T]):
|
|
|
197
201
|
self._path = path
|
|
198
202
|
self._params = params
|
|
199
203
|
self._model = model
|
|
204
|
+
self._data_key = data_key
|
|
200
205
|
|
|
201
206
|
async def next_page(self) -> AsyncCursorPage[T]:
|
|
202
207
|
return await _async_fetch_cursor_page(
|
|
@@ -204,6 +209,129 @@ class AsyncCursorPage(Generic[T]):
|
|
|
204
209
|
self._path,
|
|
205
210
|
{**self._params, "cursor": self.next_cursor},
|
|
206
211
|
self._model,
|
|
212
|
+
data_key=self._data_key,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
async def auto_paging_iter(self) -> AsyncIterator[T]:
|
|
216
|
+
page = self
|
|
217
|
+
while True:
|
|
218
|
+
for item in page.data:
|
|
219
|
+
yield item
|
|
220
|
+
if not page.has_more or not page.next_cursor:
|
|
221
|
+
break
|
|
222
|
+
page = await page.next_page()
|
|
223
|
+
|
|
224
|
+
def __iter__(self) -> Iterator[T]:
|
|
225
|
+
return iter(self.data)
|
|
226
|
+
|
|
227
|
+
def __len__(self) -> int:
|
|
228
|
+
return len(self.data)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class SyncLimitOffsetPage(Generic[T]):
|
|
232
|
+
"""`limit`/`offset` paginated response with no total (sync).
|
|
233
|
+
|
|
234
|
+
The API reports no total for these lists, so another page is assumed while
|
|
235
|
+
pages come back full. The next page starts after the rows actually
|
|
236
|
+
returned, not after the requested `limit`: the API clamps `limit`, and
|
|
237
|
+
advancing by the requested size would skip the rows in between.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
data: list[T]
|
|
241
|
+
limit: int
|
|
242
|
+
offset: int
|
|
243
|
+
|
|
244
|
+
def __init__(
|
|
245
|
+
self,
|
|
246
|
+
data: list[T],
|
|
247
|
+
limit: int,
|
|
248
|
+
offset: int,
|
|
249
|
+
*,
|
|
250
|
+
transport: SyncTransport,
|
|
251
|
+
path: str,
|
|
252
|
+
params: dict[str, Any],
|
|
253
|
+
model: type[T],
|
|
254
|
+
data_key: str,
|
|
255
|
+
) -> None:
|
|
256
|
+
self.data = data
|
|
257
|
+
self.limit = limit
|
|
258
|
+
self.offset = offset
|
|
259
|
+
self._transport = transport
|
|
260
|
+
self._path = path
|
|
261
|
+
self._params = params
|
|
262
|
+
self._model = model
|
|
263
|
+
self._data_key = data_key
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def has_more(self) -> bool:
|
|
267
|
+
return len(self.data) > 0 and len(self.data) >= self.limit
|
|
268
|
+
|
|
269
|
+
def next_page(self) -> SyncLimitOffsetPage[T]:
|
|
270
|
+
return _fetch_limit_offset_page(
|
|
271
|
+
self._transport,
|
|
272
|
+
self._path,
|
|
273
|
+
{**self._params, "offset": self.offset + len(self.data)},
|
|
274
|
+
self._model,
|
|
275
|
+
data_key=self._data_key,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def auto_paging_iter(self) -> Iterator[T]:
|
|
279
|
+
page = self
|
|
280
|
+
while True:
|
|
281
|
+
yield from page.data
|
|
282
|
+
if not page.has_more:
|
|
283
|
+
break
|
|
284
|
+
page = page.next_page()
|
|
285
|
+
|
|
286
|
+
def __iter__(self) -> Iterator[T]:
|
|
287
|
+
return iter(self.data)
|
|
288
|
+
|
|
289
|
+
def __len__(self) -> int:
|
|
290
|
+
return len(self.data)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class AsyncLimitOffsetPage(Generic[T]):
|
|
294
|
+
"""`limit`/`offset` paginated response with no total (async).
|
|
295
|
+
|
|
296
|
+
See :class:`SyncLimitOffsetPage`.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
data: list[T]
|
|
300
|
+
limit: int
|
|
301
|
+
offset: int
|
|
302
|
+
|
|
303
|
+
def __init__(
|
|
304
|
+
self,
|
|
305
|
+
data: list[T],
|
|
306
|
+
limit: int,
|
|
307
|
+
offset: int,
|
|
308
|
+
*,
|
|
309
|
+
transport: AsyncTransport,
|
|
310
|
+
path: str,
|
|
311
|
+
params: dict[str, Any],
|
|
312
|
+
model: type[T],
|
|
313
|
+
data_key: str,
|
|
314
|
+
) -> None:
|
|
315
|
+
self.data = data
|
|
316
|
+
self.limit = limit
|
|
317
|
+
self.offset = offset
|
|
318
|
+
self._transport = transport
|
|
319
|
+
self._path = path
|
|
320
|
+
self._params = params
|
|
321
|
+
self._model = model
|
|
322
|
+
self._data_key = data_key
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def has_more(self) -> bool:
|
|
326
|
+
return len(self.data) > 0 and len(self.data) >= self.limit
|
|
327
|
+
|
|
328
|
+
async def next_page(self) -> AsyncLimitOffsetPage[T]:
|
|
329
|
+
return await _async_fetch_limit_offset_page(
|
|
330
|
+
self._transport,
|
|
331
|
+
self._path,
|
|
332
|
+
{**self._params, "offset": self.offset + len(self.data)},
|
|
333
|
+
self._model,
|
|
334
|
+
data_key=self._data_key,
|
|
207
335
|
)
|
|
208
336
|
|
|
209
337
|
async def auto_paging_iter(self) -> AsyncIterator[T]:
|
|
@@ -279,9 +407,11 @@ def _fetch_cursor_page(
|
|
|
279
407
|
path: str,
|
|
280
408
|
params: dict[str, Any],
|
|
281
409
|
model: type[T],
|
|
410
|
+
*,
|
|
411
|
+
data_key: str | None = None,
|
|
282
412
|
) -> SyncCursorPage[T]:
|
|
283
413
|
resp = transport.request("GET", path, params=params)
|
|
284
|
-
items, has_more, next_cursor = _extract_cursor_data(resp)
|
|
414
|
+
items, has_more, next_cursor = _extract_cursor_data(resp, data_key)
|
|
285
415
|
return SyncCursorPage(
|
|
286
416
|
data=_parse_list(items, model),
|
|
287
417
|
has_more=has_more,
|
|
@@ -290,6 +420,7 @@ def _fetch_cursor_page(
|
|
|
290
420
|
path=path,
|
|
291
421
|
params=params,
|
|
292
422
|
model=model,
|
|
423
|
+
data_key=data_key,
|
|
293
424
|
)
|
|
294
425
|
|
|
295
426
|
|
|
@@ -298,9 +429,11 @@ async def _async_fetch_cursor_page(
|
|
|
298
429
|
path: str,
|
|
299
430
|
params: dict[str, Any],
|
|
300
431
|
model: type[T],
|
|
432
|
+
*,
|
|
433
|
+
data_key: str | None = None,
|
|
301
434
|
) -> AsyncCursorPage[T]:
|
|
302
435
|
resp = await transport.request("GET", path, params=params)
|
|
303
|
-
items, has_more, next_cursor = _extract_cursor_data(resp)
|
|
436
|
+
items, has_more, next_cursor = _extract_cursor_data(resp, data_key)
|
|
304
437
|
return AsyncCursorPage(
|
|
305
438
|
data=_parse_list(items, model),
|
|
306
439
|
has_more=has_more,
|
|
@@ -309,6 +442,51 @@ async def _async_fetch_cursor_page(
|
|
|
309
442
|
path=path,
|
|
310
443
|
params=params,
|
|
311
444
|
model=model,
|
|
445
|
+
data_key=data_key,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _fetch_limit_offset_page(
|
|
450
|
+
transport: SyncTransport,
|
|
451
|
+
path: str,
|
|
452
|
+
params: dict[str, Any],
|
|
453
|
+
model: type[T],
|
|
454
|
+
*,
|
|
455
|
+
data_key: str,
|
|
456
|
+
) -> SyncLimitOffsetPage[T]:
|
|
457
|
+
resp = transport.request("GET", path, params=params)
|
|
458
|
+
items, limit, offset = _extract_limit_offset_data(resp, data_key, params)
|
|
459
|
+
return SyncLimitOffsetPage(
|
|
460
|
+
data=_parse_list(items, model),
|
|
461
|
+
limit=limit,
|
|
462
|
+
offset=offset,
|
|
463
|
+
transport=transport,
|
|
464
|
+
path=path,
|
|
465
|
+
params=params,
|
|
466
|
+
model=model,
|
|
467
|
+
data_key=data_key,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
async def _async_fetch_limit_offset_page(
|
|
472
|
+
transport: AsyncTransport,
|
|
473
|
+
path: str,
|
|
474
|
+
params: dict[str, Any],
|
|
475
|
+
model: type[T],
|
|
476
|
+
*,
|
|
477
|
+
data_key: str,
|
|
478
|
+
) -> AsyncLimitOffsetPage[T]:
|
|
479
|
+
resp = await transport.request("GET", path, params=params)
|
|
480
|
+
items, limit, offset = _extract_limit_offset_data(resp, data_key, params)
|
|
481
|
+
return AsyncLimitOffsetPage(
|
|
482
|
+
data=_parse_list(items, model),
|
|
483
|
+
limit=limit,
|
|
484
|
+
offset=offset,
|
|
485
|
+
transport=transport,
|
|
486
|
+
path=path,
|
|
487
|
+
params=params,
|
|
488
|
+
model=model,
|
|
489
|
+
data_key=data_key,
|
|
312
490
|
)
|
|
313
491
|
|
|
314
492
|
|
|
@@ -342,14 +520,38 @@ def _extract_offset_data(
|
|
|
342
520
|
return items if isinstance(items, list) else [], total, page, page_size
|
|
343
521
|
|
|
344
522
|
|
|
345
|
-
def _extract_cursor_data(
|
|
346
|
-
|
|
523
|
+
def _extract_cursor_data(
|
|
524
|
+
resp: Any, data_key: str | None = None
|
|
525
|
+
) -> tuple[list[Any], bool, str | None]:
|
|
526
|
+
"""Extract items + pagination from a cursor-based API response.
|
|
527
|
+
|
|
528
|
+
Most cursor lists nest `hasMore`/`nextCursor` under `pagination`; inbound
|
|
529
|
+
events (`GET /api/events`) put them at the top level next to `events`.
|
|
530
|
+
"""
|
|
347
531
|
if not isinstance(resp, dict):
|
|
348
532
|
return [], False, None
|
|
349
533
|
|
|
350
|
-
items = resp.get("data", [])
|
|
351
|
-
pagination = resp.get("pagination"
|
|
534
|
+
items = resp[data_key] if data_key and data_key in resp else resp.get("data", [])
|
|
535
|
+
pagination = resp.get("pagination")
|
|
536
|
+
if not isinstance(pagination, dict):
|
|
537
|
+
pagination = resp
|
|
352
538
|
has_more = pagination.get("hasMore", pagination.get("has_more", False))
|
|
353
539
|
next_cursor = pagination.get("nextCursor", pagination.get("next_cursor"))
|
|
354
540
|
|
|
355
|
-
return items if isinstance(items, list) else [], has_more, next_cursor
|
|
541
|
+
return items if isinstance(items, list) else [], bool(has_more), next_cursor
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _extract_limit_offset_data(
|
|
545
|
+
resp: Any, data_key: str, params: dict[str, Any]
|
|
546
|
+
) -> tuple[list[Any], int, int]:
|
|
547
|
+
"""Extract items + the limit/offset the API actually applied.
|
|
548
|
+
|
|
549
|
+
The API clamps `limit` (deliveries cap it at 100) and echoes the values it
|
|
550
|
+
used, so read those back rather than trusting what was requested.
|
|
551
|
+
"""
|
|
552
|
+
if not isinstance(resp, dict):
|
|
553
|
+
return [], int(params.get("limit") or 50), int(params.get("offset") or 0)
|
|
554
|
+
items = resp.get(data_key, resp.get("data", []))
|
|
555
|
+
limit = resp.get("limit", params.get("limit") or 50)
|
|
556
|
+
offset = resp.get("offset", params.get("offset") or 0)
|
|
557
|
+
return items if isinstance(items, list) else [], int(limit), int(offset)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "4.0.0"
|
|
@@ -11,7 +11,15 @@ from .catalog import (
|
|
|
11
11
|
ProviderSignatureVerificationSummary,
|
|
12
12
|
ProviderVerifyTokenInfo,
|
|
13
13
|
)
|
|
14
|
-
from .common import
|
|
14
|
+
from .common import (
|
|
15
|
+
BulkDeleteResult,
|
|
16
|
+
ImportDetails,
|
|
17
|
+
ImportFailure,
|
|
18
|
+
ImportResult,
|
|
19
|
+
ImportResultItem,
|
|
20
|
+
ImportSummary,
|
|
21
|
+
ImportValidationResult,
|
|
22
|
+
)
|
|
15
23
|
from .cron_jobs import (
|
|
16
24
|
CreateCronGroupParams,
|
|
17
25
|
CreateCronJobParams,
|
|
@@ -146,6 +154,10 @@ __all__ = [
|
|
|
146
154
|
"BulkDeleteResult",
|
|
147
155
|
"ImportResult",
|
|
148
156
|
"ImportResultItem",
|
|
157
|
+
"ImportSummary",
|
|
158
|
+
"ImportDetails",
|
|
159
|
+
"ImportFailure",
|
|
160
|
+
"ImportValidationResult",
|
|
149
161
|
# Cron Jobs
|
|
150
162
|
"CreateCronGroupParams",
|
|
151
163
|
"CreateCronJobParams",
|
|
@@ -14,6 +14,19 @@ class Application(HookbaseModel):
|
|
|
14
14
|
organization_id: str
|
|
15
15
|
external_id: str = ""
|
|
16
16
|
metadata: dict[str, Any] | None = None
|
|
17
|
+
rate_limit_per_second: int | None = None
|
|
18
|
+
rate_limit_per_minute: int | None = None
|
|
19
|
+
rate_limit_per_hour: int | None = None
|
|
20
|
+
is_disabled: bool = False
|
|
21
|
+
disabled_at: str | None = None
|
|
22
|
+
disabled_reason: str | None = None
|
|
23
|
+
total_endpoints: int | None = None
|
|
24
|
+
total_messages_sent: int | None = None
|
|
25
|
+
total_messages_failed: int | None = None
|
|
26
|
+
last_event_at: str | None = None
|
|
27
|
+
send_event_url: str | None = None
|
|
28
|
+
created_by: str | None = None
|
|
29
|
+
api_key_id: str | None = None
|
|
17
30
|
created_at: str = ""
|
|
18
31
|
updated_at: str = ""
|
|
19
32
|
|
|
@@ -45,6 +58,9 @@ class CreateApplicationParams(HookbaseModel):
|
|
|
45
58
|
name: str
|
|
46
59
|
external_id: str | None = None
|
|
47
60
|
metadata: dict[str, Any] | None = None
|
|
61
|
+
rate_limit_per_second: int | None = None
|
|
62
|
+
rate_limit_per_minute: int | None = None
|
|
63
|
+
rate_limit_per_hour: int | None = None
|
|
48
64
|
|
|
49
65
|
uid: str | None = None
|
|
50
66
|
"""Deprecated: renamed to `external_id`, which is the only name the API
|
|
@@ -55,6 +71,11 @@ class CreateApplicationParams(HookbaseModel):
|
|
|
55
71
|
class UpdateApplicationParams(HookbaseModel):
|
|
56
72
|
name: str | None = None
|
|
57
73
|
metadata: dict[str, Any] | None = None
|
|
74
|
+
rate_limit_per_second: int | None = None
|
|
75
|
+
rate_limit_per_minute: int | None = None
|
|
76
|
+
rate_limit_per_hour: int | None = None
|
|
77
|
+
is_disabled: bool | None = None
|
|
78
|
+
disabled_reason: str | None = None
|
|
58
79
|
|
|
59
80
|
|
|
60
81
|
def application_body(params: CreateApplicationParams | dict[str, Any]) -> dict[str, Any]:
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._base import HookbaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ImportResultItem(HookbaseModel):
|
|
9
|
+
"""Deprecated: no import response carries per-item results in this shape.
|
|
10
|
+
See :class:`ImportResult`."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
status: str
|
|
14
|
+
error: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ImportFailure(HookbaseModel):
|
|
18
|
+
"""An item that failed to import, and why."""
|
|
19
|
+
|
|
20
|
+
name: str = ""
|
|
21
|
+
error: str = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ImportSummary(HookbaseModel):
|
|
25
|
+
"""Counts for an import. Which keys are set depends on the resource and on
|
|
26
|
+
whether it was a `validate_only` run."""
|
|
27
|
+
|
|
28
|
+
total: int | None = None
|
|
29
|
+
imported: int | None = None
|
|
30
|
+
skipped: int | None = None
|
|
31
|
+
overwritten: int | None = None
|
|
32
|
+
failed: int | None = None
|
|
33
|
+
errors: int | None = None
|
|
34
|
+
to_create: int | None = None
|
|
35
|
+
to_overwrite: int | None = None
|
|
36
|
+
to_skip: int | None = None
|
|
37
|
+
valid: int | None = None
|
|
38
|
+
overwrite: int | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ImportDetails(HookbaseModel):
|
|
42
|
+
"""Names of the items in each outcome (sources and destinations imports)."""
|
|
43
|
+
|
|
44
|
+
imported: list[str] = []
|
|
45
|
+
skipped: list[str] = []
|
|
46
|
+
overwritten: list[str] = []
|
|
47
|
+
failed: list[ImportFailure] = []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ImportValidationResult(HookbaseModel):
|
|
51
|
+
"""How one item fared in validation."""
|
|
52
|
+
|
|
53
|
+
index: int = 0
|
|
54
|
+
name: str = ""
|
|
55
|
+
slug: str | None = None
|
|
56
|
+
status: str = ""
|
|
57
|
+
"""`valid`, `error`, `skip`, `rename` or `overwrite`."""
|
|
58
|
+
errors: list[str] = []
|
|
59
|
+
warnings: list[str] = []
|
|
60
|
+
resolved_slug: str | None = None
|
|
61
|
+
resolved_name: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ImportResult(HookbaseModel):
|
|
65
|
+
"""The result of `import_sources`, `import_destinations` or `import_routes`.
|
|
66
|
+
|
|
67
|
+
A `validate_only` run sets `valid` (sources, destinations), `validation_results`
|
|
68
|
+
and a `summary` of what would happen. A real import sets `success` and a
|
|
69
|
+
`summary`; sources and destinations list the item names in `details`, while
|
|
70
|
+
routes put them in `imported`, `skipped` and `errors`.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
success: bool = False
|
|
74
|
+
valid: bool | None = None
|
|
75
|
+
validate_only: bool = False
|
|
76
|
+
summary: ImportSummary | None = None
|
|
77
|
+
details: ImportDetails | None = None
|
|
78
|
+
validation_results: list[ImportValidationResult] = []
|
|
79
|
+
imported: list[str] = []
|
|
80
|
+
"""Route imports only; sources and destinations use `details.imported`."""
|
|
81
|
+
skipped: list[str] = []
|
|
82
|
+
"""Route imports only; sources and destinations use `details.skipped`."""
|
|
83
|
+
errors: list[ImportFailure] = []
|
|
84
|
+
"""Route imports only; sources and destinations use `details.failed`."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class BulkDeleteResult(HookbaseModel):
|
|
88
|
+
success: bool = True
|
|
89
|
+
deleted: int = 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PaginationInfo(HookbaseModel):
|
|
93
|
+
total: int = 0
|
|
94
|
+
page: int = 1
|
|
95
|
+
page_size: int = 20
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CursorPaginationInfo(HookbaseModel):
|
|
99
|
+
has_more: bool = False
|
|
100
|
+
next_cursor: str | None = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ApiListResponse(HookbaseModel):
|
|
104
|
+
"""Generic wrapper for paginated list API responses."""
|
|
105
|
+
|
|
106
|
+
data: list[Any] = []
|
|
107
|
+
pagination: PaginationInfo | None = None
|