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.
Files changed (96) hide show
  1. {hookbase-3.2.0 → hookbase-4.0.0}/.github/workflows/python-publish.yml +3 -1
  2. {hookbase-3.2.0 → hookbase-4.0.0}/PKG-INFO +9 -3
  3. {hookbase-3.2.0 → hookbase-4.0.0}/README.md +8 -2
  4. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/_client.py +39 -7
  5. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/_pagination.py +210 -8
  6. hookbase-4.0.0/src/hookbase/_version.py +1 -0
  7. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/__init__.py +13 -1
  8. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/applications.py +21 -0
  9. hookbase-4.0.0/src/hookbase/models/common.py +107 -0
  10. hookbase-4.0.0/src/hookbase/models/deliveries.py +136 -0
  11. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/destinations.py +24 -6
  12. hookbase-4.0.0/src/hookbase/models/dlq.py +142 -0
  13. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/endpoints.py +17 -0
  14. hookbase-4.0.0/src/hookbase/models/event_types.py +103 -0
  15. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/events.py +33 -10
  16. hookbase-4.0.0/src/hookbase/models/messages.py +168 -0
  17. hookbase-4.0.0/src/hookbase/models/subscriptions.py +78 -0
  18. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/transforms.py +20 -1
  19. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/deliveries.py +28 -12
  20. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/dlq.py +29 -10
  21. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/endpoints.py +53 -4
  22. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/event_types.py +6 -5
  23. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/events.py +25 -17
  24. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/message_log.py +63 -12
  25. hookbase-4.0.0/src/hookbase/resources/messages.py +115 -0
  26. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/subscriptions.py +14 -4
  27. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/webhook.py +18 -1
  28. {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_destinations.py +9 -5
  29. {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_dlq.py +20 -7
  30. hookbase-4.0.0/tests/resources/test_inbound_contract.py +464 -0
  31. {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_messages.py +6 -4
  32. hookbase-4.0.0/tests/resources/test_outbound_contract.py +736 -0
  33. {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_sources.py +42 -4
  34. {hookbase-3.2.0 → hookbase-4.0.0}/tests/test_webhook.py +60 -0
  35. hookbase-3.2.0/src/hookbase/_version.py +0 -1
  36. hookbase-3.2.0/src/hookbase/models/common.py +0 -42
  37. hookbase-3.2.0/src/hookbase/models/deliveries.py +0 -55
  38. hookbase-3.2.0/src/hookbase/models/dlq.py +0 -60
  39. hookbase-3.2.0/src/hookbase/models/event_types.py +0 -37
  40. hookbase-3.2.0/src/hookbase/models/messages.py +0 -73
  41. hookbase-3.2.0/src/hookbase/models/subscriptions.py +0 -22
  42. hookbase-3.2.0/src/hookbase/resources/messages.py +0 -70
  43. {hookbase-3.2.0 → hookbase-4.0.0}/.gitignore +0 -0
  44. {hookbase-3.2.0 → hookbase-4.0.0}/LICENSE +0 -0
  45. {hookbase-3.2.0 → hookbase-4.0.0}/examples/async_usage.py +0 -0
  46. {hookbase-3.2.0 → hookbase-4.0.0}/examples/basic_inbound.py +0 -0
  47. {hookbase-3.2.0 → hookbase-4.0.0}/examples/error_handling.py +0 -0
  48. {hookbase-3.2.0 → hookbase-4.0.0}/examples/send_webhooks.py +0 -0
  49. {hookbase-3.2.0 → hookbase-4.0.0}/examples/verify_webhooks.py +0 -0
  50. {hookbase-3.2.0 → hookbase-4.0.0}/pyproject.toml +0 -0
  51. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/__init__.py +0 -0
  52. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/_constants.py +0 -0
  53. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/client.py +0 -0
  54. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/errors.py +0 -0
  55. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/_base.py +0 -0
  56. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/_wire.py +0 -0
  57. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/analytics.py +0 -0
  58. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/api_keys.py +0 -0
  59. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/catalog.py +0 -0
  60. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/cron_jobs.py +0 -0
  61. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/filters.py +0 -0
  62. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/organizations.py +0 -0
  63. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/portal_tokens.py +0 -0
  64. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/routes.py +0 -0
  65. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/schemas.py +0 -0
  66. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/sources.py +0 -0
  67. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/models/tunnels.py +0 -0
  68. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/py.typed +0 -0
  69. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/__init__.py +0 -0
  70. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/_base.py +0 -0
  71. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/analytics.py +0 -0
  72. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/api_keys.py +0 -0
  73. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/applications.py +0 -0
  74. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/cron_jobs.py +0 -0
  75. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/destinations.py +0 -0
  76. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/filters.py +0 -0
  77. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/organizations.py +0 -0
  78. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/portal_tokens.py +0 -0
  79. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/providers.py +0 -0
  80. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/routes.py +0 -0
  81. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/schemas.py +0 -0
  82. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/sources.py +0 -0
  83. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/transforms.py +0 -0
  84. {hookbase-3.2.0 → hookbase-4.0.0}/src/hookbase/resources/tunnels.py +0 -0
  85. {hookbase-3.2.0 → hookbase-4.0.0}/tests/__init__.py +0 -0
  86. {hookbase-3.2.0 → hookbase-4.0.0}/tests/conftest.py +0 -0
  87. {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/__init__.py +0 -0
  88. {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_applications.py +0 -0
  89. {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_cron_jobs.py +0 -0
  90. {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_endpoints.py +0 -0
  91. {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_source_contract.py +0 -0
  92. {hookbase-3.2.0 → hookbase-4.0.0}/tests/resources/test_source_fields.py +0 -0
  93. {hookbase-3.2.0 → hookbase-4.0.0}/tests/test_client.py +0 -0
  94. {hookbase-3.2.0 → hookbase-4.0.0}/tests/test_errors.py +0 -0
  95. {hookbase-3.2.0 → hookbase-4.0.0}/tests/test_pagination.py +0 -0
  96. {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
- 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.2.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
@@ -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.json()
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 and 409)
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.json()
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(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",
@@ -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