hookbase 3.3.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.
Files changed (91) hide show
  1. {hookbase-3.3.0 → hookbase-4.0.0}/.github/workflows/python-publish.yml +3 -1
  2. {hookbase-3.3.0 → hookbase-4.0.0}/PKG-INFO +9 -3
  3. {hookbase-3.3.0 → hookbase-4.0.0}/README.md +8 -2
  4. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/_pagination.py +209 -7
  5. hookbase-4.0.0/src/hookbase/_version.py +1 -0
  6. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/__init__.py +13 -1
  7. hookbase-4.0.0/src/hookbase/models/common.py +107 -0
  8. hookbase-4.0.0/src/hookbase/models/deliveries.py +136 -0
  9. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/destinations.py +24 -6
  10. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/events.py +33 -10
  11. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/transforms.py +20 -1
  12. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/deliveries.py +28 -12
  13. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/events.py +25 -17
  14. {hookbase-3.3.0 → hookbase-4.0.0}/tests/resources/test_destinations.py +9 -5
  15. hookbase-4.0.0/tests/resources/test_inbound_contract.py +464 -0
  16. {hookbase-3.3.0 → hookbase-4.0.0}/tests/resources/test_sources.py +42 -4
  17. hookbase-3.3.0/src/hookbase/_version.py +0 -1
  18. hookbase-3.3.0/src/hookbase/models/common.py +0 -42
  19. hookbase-3.3.0/src/hookbase/models/deliveries.py +0 -55
  20. {hookbase-3.3.0 → hookbase-4.0.0}/.gitignore +0 -0
  21. {hookbase-3.3.0 → hookbase-4.0.0}/LICENSE +0 -0
  22. {hookbase-3.3.0 → hookbase-4.0.0}/examples/async_usage.py +0 -0
  23. {hookbase-3.3.0 → hookbase-4.0.0}/examples/basic_inbound.py +0 -0
  24. {hookbase-3.3.0 → hookbase-4.0.0}/examples/error_handling.py +0 -0
  25. {hookbase-3.3.0 → hookbase-4.0.0}/examples/send_webhooks.py +0 -0
  26. {hookbase-3.3.0 → hookbase-4.0.0}/examples/verify_webhooks.py +0 -0
  27. {hookbase-3.3.0 → hookbase-4.0.0}/pyproject.toml +0 -0
  28. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/__init__.py +0 -0
  29. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/_client.py +0 -0
  30. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/_constants.py +0 -0
  31. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/client.py +0 -0
  32. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/errors.py +0 -0
  33. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/_base.py +0 -0
  34. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/_wire.py +0 -0
  35. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/analytics.py +0 -0
  36. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/api_keys.py +0 -0
  37. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/applications.py +0 -0
  38. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/catalog.py +0 -0
  39. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/cron_jobs.py +0 -0
  40. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/dlq.py +0 -0
  41. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/endpoints.py +0 -0
  42. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/event_types.py +0 -0
  43. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/filters.py +0 -0
  44. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/messages.py +0 -0
  45. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/organizations.py +0 -0
  46. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/portal_tokens.py +0 -0
  47. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/routes.py +0 -0
  48. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/schemas.py +0 -0
  49. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/sources.py +0 -0
  50. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/subscriptions.py +0 -0
  51. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/models/tunnels.py +0 -0
  52. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/py.typed +0 -0
  53. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/__init__.py +0 -0
  54. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/_base.py +0 -0
  55. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/analytics.py +0 -0
  56. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/api_keys.py +0 -0
  57. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/applications.py +0 -0
  58. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/cron_jobs.py +0 -0
  59. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/destinations.py +0 -0
  60. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/dlq.py +0 -0
  61. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/endpoints.py +0 -0
  62. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/event_types.py +0 -0
  63. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/filters.py +0 -0
  64. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/message_log.py +0 -0
  65. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/messages.py +0 -0
  66. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/organizations.py +0 -0
  67. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/portal_tokens.py +0 -0
  68. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/providers.py +0 -0
  69. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/routes.py +0 -0
  70. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/schemas.py +0 -0
  71. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/sources.py +0 -0
  72. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/subscriptions.py +0 -0
  73. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/transforms.py +0 -0
  74. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/resources/tunnels.py +0 -0
  75. {hookbase-3.3.0 → hookbase-4.0.0}/src/hookbase/webhook.py +0 -0
  76. {hookbase-3.3.0 → hookbase-4.0.0}/tests/__init__.py +0 -0
  77. {hookbase-3.3.0 → hookbase-4.0.0}/tests/conftest.py +0 -0
  78. {hookbase-3.3.0 → hookbase-4.0.0}/tests/resources/__init__.py +0 -0
  79. {hookbase-3.3.0 → hookbase-4.0.0}/tests/resources/test_applications.py +0 -0
  80. {hookbase-3.3.0 → hookbase-4.0.0}/tests/resources/test_cron_jobs.py +0 -0
  81. {hookbase-3.3.0 → hookbase-4.0.0}/tests/resources/test_dlq.py +0 -0
  82. {hookbase-3.3.0 → hookbase-4.0.0}/tests/resources/test_endpoints.py +0 -0
  83. {hookbase-3.3.0 → hookbase-4.0.0}/tests/resources/test_messages.py +0 -0
  84. {hookbase-3.3.0 → hookbase-4.0.0}/tests/resources/test_outbound_contract.py +0 -0
  85. {hookbase-3.3.0 → hookbase-4.0.0}/tests/resources/test_source_contract.py +0 -0
  86. {hookbase-3.3.0 → hookbase-4.0.0}/tests/resources/test_source_fields.py +0 -0
  87. {hookbase-3.3.0 → hookbase-4.0.0}/tests/test_client.py +0 -0
  88. {hookbase-3.3.0 → hookbase-4.0.0}/tests/test_errors.py +0 -0
  89. {hookbase-3.3.0 → hookbase-4.0.0}/tests/test_pagination.py +0 -0
  90. {hookbase-3.3.0 → hookbase-4.0.0}/tests/test_slug_contract.py +0 -0
  91. {hookbase-3.3.0 → hookbase-4.0.0}/tests/test_webhook.py +0 -0
@@ -13,7 +13,9 @@ permissions:
13
13
 
14
14
  jobs:
15
15
  test:
16
- runs-on: ubuntu-latest
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.3.0
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("txf_id", payload={"data": {"key": "value"}})
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("txf_id", payload={"data": {"key": "value"}})
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
@@ -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,6 +157,7 @@ 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]:
@@ -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,7 @@ 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,
207
213
  )
208
214
 
209
215
  async def auto_paging_iter(self) -> AsyncIterator[T]:
@@ -222,6 +228,128 @@ class AsyncCursorPage(Generic[T]):
222
228
  return len(self.data)
223
229
 
224
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,
335
+ )
336
+
337
+ async def auto_paging_iter(self) -> AsyncIterator[T]:
338
+ page = self
339
+ while True:
340
+ for item in page.data:
341
+ yield item
342
+ if not page.has_more:
343
+ break
344
+ page = await page.next_page()
345
+
346
+ def __iter__(self) -> Iterator[T]:
347
+ return iter(self.data)
348
+
349
+ def __len__(self) -> int:
350
+ return len(self.data)
351
+
352
+
225
353
  # --- Helper functions for fetching pages ---
226
354
 
227
355
 
@@ -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(resp: Any) -> tuple[list[Any], bool, str | None]:
346
- """Extract items + pagination from a cursor-based API response."""
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 BulkDeleteResult, ImportResult, ImportResultItem
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",
@@ -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
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from pydantic import field_validator, model_validator
7
+ from pydantic.alias_generators import to_camel
8
+
9
+ from ._base import HookbaseModel
10
+
11
+ # API may return: pending, success, failed, retrying, delivered,
12
+ # failed_over, schema_failed, exhausted, etc.
13
+ DeliveryStatus = str
14
+
15
+
16
+ class Delivery(HookbaseModel):
17
+ id: str
18
+ event_id: str
19
+ route_id: str
20
+ destination_id: str
21
+ organization_id: str
22
+ status: DeliveryStatus = "pending"
23
+ attempt_count: int = 0
24
+ max_attempts: int = 5
25
+ next_retry_at: str | None = None
26
+ response_status: int | None = None
27
+ response_key: str | None = None
28
+ response_headers: dict[str, Any] | None = None
29
+ response_body: str | None = None
30
+ latency_ms: int | None = None
31
+ error_message: str | None = None
32
+ delivered_at: str | None = None
33
+ created_at: str = ""
34
+ updated_at: str | None = None
35
+ destination_name: str | None = None
36
+ destination_url: str | None = None
37
+ """Set by `deliveries.get` and on `events.get` deliveries; not by `deliveries.list`."""
38
+ event_type: str | None = None
39
+ """Set by `deliveries.list` only."""
40
+
41
+ # Deprecated: the SDK's older names for fields the API returns as
42
+ # `responseStatus`, `attemptCount`, `errorMessage`, `latencyMs` and
43
+ # `deliveredAt`. Never sent by the API, so they used to be always empty;
44
+ # now kept in step with the real fields so existing attribute access works.
45
+ status_code: int | None = None
46
+ """Deprecated: read `response_status`."""
47
+ attempts: int = 0
48
+ """Deprecated: read `attempt_count`."""
49
+ error: str | None = None
50
+ """Deprecated: read `error_message`."""
51
+ duration: float | None = None
52
+ """Deprecated: read `latency_ms`."""
53
+ completed_at: str | None = None
54
+ """Deprecated: read `delivered_at`."""
55
+
56
+ @field_validator("attempt_count", "max_attempts", mode="before")
57
+ @classmethod
58
+ def _null_counts_to_default(cls, v: Any, info: Any) -> Any:
59
+ # The API sends null for a delivery the worker has not counted yet; a field default only
60
+ # covers a missing key, so without this every list/get containing one failed to validate.
61
+ if v is None:
62
+ return cls.model_fields[info.field_name].default
63
+ return v
64
+
65
+ @field_validator("response_headers", mode="before")
66
+ @classmethod
67
+ def _parse_response_headers(cls, v: Any) -> Any:
68
+ # Stored as JSON text, so the API usually sends a string.
69
+ if isinstance(v, str):
70
+ try:
71
+ parsed = json.loads(v)
72
+ except (json.JSONDecodeError, ValueError):
73
+ return None
74
+ return parsed if isinstance(parsed, dict) else None
75
+ return v
76
+
77
+ @model_validator(mode="before")
78
+ @classmethod
79
+ def _mirror_deprecated(cls, data: Any) -> Any:
80
+ """Copy each real field onto its deprecated name, whichever spelling it came in."""
81
+ if not isinstance(data, dict):
82
+ return data
83
+ out = dict(data)
84
+ for new, old in _DEPRECATED_DELIVERY_FIELDS:
85
+ value = out.get(to_camel(new))
86
+ if value is None:
87
+ value = out.get(new)
88
+ if value is not None:
89
+ out.pop(old, None)
90
+ out[to_camel(old)] = value
91
+ return out
92
+
93
+
94
+ # (current field, deprecated field kept in step with it)
95
+ _DEPRECATED_DELIVERY_FIELDS = (
96
+ ("response_status", "status_code"),
97
+ ("attempt_count", "attempts"),
98
+ ("error_message", "error"),
99
+ ("latency_ms", "duration"),
100
+ ("delivered_at", "completed_at"),
101
+ )
102
+
103
+
104
+ class DeliveryEvent(HookbaseModel):
105
+ id: str
106
+ event_type: str | None = None
107
+ received_at: str = ""
108
+
109
+
110
+ class DeliveryDestination(HookbaseModel):
111
+ name: str
112
+ url: str
113
+
114
+
115
+ class DeliveryDetail(Delivery):
116
+ # Deprecated: `GET /api/deliveries/:id` has never returned nested `event` or
117
+ # `destination` objects, so these are always None. Read `event_id`,
118
+ # `destination_name` and `destination_url` instead.
119
+ event: DeliveryEvent | None = None
120
+ destination: DeliveryDestination | None = None
121
+
122
+
123
+ class ReplayResult(HookbaseModel):
124
+ delivery_id: str
125
+ message: str = ""
126
+
127
+
128
+ class BulkReplayResult(HookbaseModel):
129
+ message: str = ""
130
+ queued: int = 0
131
+ skipped: int = 0
132
+ events_processed: int | None = None
133
+ """Set by `bulk_replay_events` only."""
134
+ overrides: dict[str, Any] | None = None
135
+ """Set by `bulk_replay_events` only."""
136
+ results: list[dict[str, Any]] = []
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  from typing import Any, Literal
5
5
 
6
- from pydantic import Field, field_validator, model_validator
6
+ from pydantic import field_validator, model_validator
7
7
 
8
8
  from . import _wire
9
9
  from ._base import HookbaseModel
@@ -317,10 +317,28 @@ def update_destination_body(
317
317
 
318
318
  class TestResult(HookbaseModel):
319
319
  success: bool
320
- # The API answers with `status`/`latencyMs` (api/src/routes/destinations.ts), not the
321
- # `statusCode`/`duration` that HookbaseModel's to_camel alias_generator would otherwise
322
- # expect for these field names — override the alias explicitly rather than rely on it.
323
- status_code: int | None = Field(default=None, alias="status")
324
- duration: float | None = Field(default=None, alias="latencyMs")
320
+ status: int | None = None
321
+ """The HTTP status the destination answered with."""
322
+ latency_ms: int | None = None
325
323
  response_body: str | None = None
326
324
  error: str | None = None
325
+
326
+ status_code: int | None = None
327
+ """Deprecated: the API calls this `status`; read `status` instead. Kept in
328
+ step with it so existing attribute access keeps working."""
329
+ duration: float | None = None
330
+ """Deprecated: the API calls this `latencyMs`; read `latency_ms` instead.
331
+ Kept in step with it so existing attribute access keeps working."""
332
+
333
+ @model_validator(mode="before")
334
+ @classmethod
335
+ def _mirror_deprecated(cls, data: Any) -> Any:
336
+ if not isinstance(data, dict):
337
+ return data
338
+ out = dict(data)
339
+ if out.get("status") is not None:
340
+ out["statusCode"] = out["status"]
341
+ latency = out.get("latencyMs", out.get("latency_ms"))
342
+ if latency is not None:
343
+ out["duration"] = latency
344
+ return out