hookbase 3.0.0__tar.gz → 3.1.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 (87) hide show
  1. {hookbase-3.0.0 → hookbase-3.1.0}/PKG-INFO +6 -2
  2. {hookbase-3.0.0 → hookbase-3.1.0}/README.md +5 -1
  3. {hookbase-3.0.0 → hookbase-3.1.0}/examples/async_usage.py +1 -1
  4. {hookbase-3.0.0 → hookbase-3.1.0}/examples/basic_inbound.py +1 -1
  5. {hookbase-3.0.0 → hookbase-3.1.0}/examples/send_webhooks.py +1 -1
  6. hookbase-3.1.0/src/hookbase/_version.py +1 -0
  7. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/__init__.py +8 -0
  8. hookbase-3.1.0/src/hookbase/models/_wire.py +254 -0
  9. hookbase-3.1.0/src/hookbase/models/applications.py +70 -0
  10. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/destinations.py +120 -12
  11. hookbase-3.1.0/src/hookbase/models/endpoints.py +275 -0
  12. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/_base.py +8 -2
  13. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/applications.py +38 -6
  14. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/destinations.py +7 -5
  15. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/endpoints.py +18 -7
  16. {hookbase-3.0.0 → hookbase-3.1.0}/tests/conftest.py +6 -0
  17. hookbase-3.1.0/tests/resources/test_applications.py +138 -0
  18. {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/test_destinations.py +196 -7
  19. hookbase-3.1.0/tests/resources/test_endpoints.py +432 -0
  20. hookbase-3.1.0/tests/test_slug_contract.py +143 -0
  21. hookbase-3.0.0/src/hookbase/_version.py +0 -1
  22. hookbase-3.0.0/src/hookbase/models/applications.py +0 -26
  23. hookbase-3.0.0/src/hookbase/models/endpoints.py +0 -79
  24. hookbase-3.0.0/tests/resources/test_applications.py +0 -77
  25. {hookbase-3.0.0 → hookbase-3.1.0}/.github/workflows/python-publish.yml +0 -0
  26. {hookbase-3.0.0 → hookbase-3.1.0}/.gitignore +0 -0
  27. {hookbase-3.0.0 → hookbase-3.1.0}/LICENSE +0 -0
  28. {hookbase-3.0.0 → hookbase-3.1.0}/examples/error_handling.py +0 -0
  29. {hookbase-3.0.0 → hookbase-3.1.0}/examples/verify_webhooks.py +0 -0
  30. {hookbase-3.0.0 → hookbase-3.1.0}/pyproject.toml +0 -0
  31. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/__init__.py +0 -0
  32. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/_client.py +0 -0
  33. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/_constants.py +0 -0
  34. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/_pagination.py +0 -0
  35. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/client.py +0 -0
  36. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/errors.py +0 -0
  37. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/_base.py +0 -0
  38. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/analytics.py +0 -0
  39. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/api_keys.py +0 -0
  40. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/common.py +0 -0
  41. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/cron_jobs.py +0 -0
  42. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/deliveries.py +0 -0
  43. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/dlq.py +0 -0
  44. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/event_types.py +0 -0
  45. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/events.py +0 -0
  46. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/filters.py +0 -0
  47. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/messages.py +0 -0
  48. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/organizations.py +0 -0
  49. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/portal_tokens.py +0 -0
  50. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/routes.py +0 -0
  51. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/schemas.py +0 -0
  52. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/sources.py +0 -0
  53. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/subscriptions.py +0 -0
  54. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/transforms.py +0 -0
  55. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/models/tunnels.py +0 -0
  56. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/py.typed +0 -0
  57. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/__init__.py +0 -0
  58. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/analytics.py +0 -0
  59. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/api_keys.py +0 -0
  60. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/cron_jobs.py +0 -0
  61. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/deliveries.py +0 -0
  62. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/dlq.py +0 -0
  63. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/event_types.py +0 -0
  64. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/events.py +0 -0
  65. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/filters.py +0 -0
  66. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/message_log.py +0 -0
  67. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/messages.py +0 -0
  68. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/organizations.py +0 -0
  69. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/portal_tokens.py +0 -0
  70. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/routes.py +0 -0
  71. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/schemas.py +0 -0
  72. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/sources.py +0 -0
  73. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/subscriptions.py +0 -0
  74. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/transforms.py +0 -0
  75. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/resources/tunnels.py +0 -0
  76. {hookbase-3.0.0 → hookbase-3.1.0}/src/hookbase/webhook.py +0 -0
  77. {hookbase-3.0.0 → hookbase-3.1.0}/tests/__init__.py +0 -0
  78. {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/__init__.py +0 -0
  79. {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/test_cron_jobs.py +0 -0
  80. {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/test_dlq.py +0 -0
  81. {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/test_messages.py +0 -0
  82. {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/test_source_fields.py +0 -0
  83. {hookbase-3.0.0 → hookbase-3.1.0}/tests/resources/test_sources.py +0 -0
  84. {hookbase-3.0.0 → hookbase-3.1.0}/tests/test_client.py +0 -0
  85. {hookbase-3.0.0 → hookbase-3.1.0}/tests/test_errors.py +0 -0
  86. {hookbase-3.0.0 → hookbase-3.1.0}/tests/test_pagination.py +0 -0
  87. {hookbase-3.0.0 → hookbase-3.1.0}/tests/test_webhook.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hookbase
3
- Version: 3.0.0
3
+ Version: 3.1.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
@@ -129,13 +129,17 @@ client.schemas.create({"name": "OrderSchema", "jsonSchema": {"type": "object", .
129
129
 
130
130
  ```python
131
131
  # Applications
132
- client.outbound.applications.create({"name": "Acme", "uid": "cust_123"})
132
+ client.outbound.applications.create({"name": "Acme", "externalId": "cust_123"})
133
133
  client.outbound.applications.get_by_external_id("cust_123")
134
134
 
135
135
  # Endpoints
136
136
  client.outbound.endpoints.create("app_id", {"url": "https://..."})
137
137
  client.outbound.endpoints.rotate_secret("ep_id", grace_period=3600)
138
138
 
139
+ # An unset field is left alone by an update, so `None` cannot also mean "reset
140
+ # this". Name the setting in `clear` to send an explicit null for it:
141
+ client.outbound.endpoints.update("ep_id", {"clear": ["retryDelays"]})
142
+
139
143
  # Event Types & Subscriptions
140
144
  client.outbound.event_types.create({"name": "order.created", "category": "orders"})
141
145
  client.outbound.subscriptions.create({"endpointId": "ep_id", "eventTypeId": "et_id"})
@@ -94,13 +94,17 @@ client.schemas.create({"name": "OrderSchema", "jsonSchema": {"type": "object", .
94
94
 
95
95
  ```python
96
96
  # Applications
97
- client.outbound.applications.create({"name": "Acme", "uid": "cust_123"})
97
+ client.outbound.applications.create({"name": "Acme", "externalId": "cust_123"})
98
98
  client.outbound.applications.get_by_external_id("cust_123")
99
99
 
100
100
  # Endpoints
101
101
  client.outbound.endpoints.create("app_id", {"url": "https://..."})
102
102
  client.outbound.endpoints.rotate_secret("ep_id", grace_period=3600)
103
103
 
104
+ # An unset field is left alone by an update, so `None` cannot also mean "reset
105
+ # this". Name the setting in `clear` to send an explicit null for it:
106
+ client.outbound.endpoints.update("ep_id", {"clear": ["retryDelays"]})
107
+
104
108
  # Event Types & Subscriptions
105
109
  client.outbound.event_types.create({"name": "order.created", "category": "orders"})
106
110
  client.outbound.subscriptions.create({"endpointId": "ep_id", "eventTypeId": "et_id"})
@@ -29,7 +29,7 @@ async def main():
29
29
  # Paginate through all applications
30
30
  page = await client.outbound.applications.list(limit=10)
31
31
  async for app in page.auto_paging_iter():
32
- print(f" App: {app.name} (uid={app.uid})")
32
+ print(f" App: {app.name} (externalId={app.external_id})")
33
33
 
34
34
 
35
35
  if __name__ == "__main__":
@@ -21,7 +21,7 @@ dest = client.destinations.create({
21
21
  "slug": "backend",
22
22
  "url": "https://api.example.com/webhooks",
23
23
  "method": "POST",
24
- "retryCount": 3,
24
+ "timeoutMs": 30000,
25
25
  })
26
26
  print(f"Destination created: {dest.id}")
27
27
 
@@ -7,7 +7,7 @@ client = Hookbase(api_key="whr_your_api_key")
7
7
  # Create an application (represents a customer/tenant)
8
8
  app = client.outbound.applications.create({
9
9
  "name": "Acme Corp",
10
- "uid": "cust_123",
10
+ "externalId": "cust_123",
11
11
  "metadata": {"plan": "pro"},
12
12
  })
13
13
  print(f"Application: {app.id}")
@@ -0,0 +1 @@
1
+ __version__ = "3.1.0"
@@ -42,7 +42,11 @@ from .dlq import (
42
42
  DlqStats,
43
43
  )
44
44
  from .endpoints import (
45
+ CLEARABLE_ENDPOINT_FIELDS,
46
+ BackoffType,
47
+ ClearableEndpointField,
45
48
  CreateEndpointParams,
49
+ CustomHeader,
46
50
  EndpointStats,
47
51
  EndpointWithSecret,
48
52
  RotateSecretResult,
@@ -155,7 +159,11 @@ __all__ = [
155
159
  "DlqRetryResult",
156
160
  "DlqStats",
157
161
  # Endpoints
162
+ "CLEARABLE_ENDPOINT_FIELDS",
163
+ "BackoffType",
164
+ "ClearableEndpointField",
158
165
  "CreateEndpointParams",
166
+ "CustomHeader",
159
167
  "EndpointStats",
160
168
  "EndpointWithSecret",
161
169
  "RotateSecretResult",
@@ -0,0 +1,254 @@
1
+ """Normalization of request bodies onto the shapes the API actually accepts.
2
+
3
+ The SDK has historically put field names on the wire that the API never
4
+ accepted (endpoint `filterTypes`/`rateLimitPeriod`/`metadata`, destination
5
+ `description`/`retryCount`/`retryInterval`) alongside a few it renamed
6
+ (`rateLimit` -> `rateLimitPerSecond`, `timeout` -> `timeoutMs`, `uid` ->
7
+ `externalId`). The API validates with `z.object()`, which silently strips
8
+ unknown keys, so those requests came back 2xx with the field quietly ignored —
9
+ and the resource configured differently from what the caller asked for. Those
10
+ schemas are moving to `.strict()`, at which point the same requests become
11
+ 400s.
12
+
13
+ Deleting the public fields would break callers, so the models keep them,
14
+ document them as deprecated, and these helpers translate them at the wire
15
+ boundary. They deliberately operate on the already-serialized body rather than
16
+ on the model, so a caller who passes a plain dict — which every resource method
17
+ accepts, and which skips model validation entirely — gets the same translation.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ import unicodedata
25
+ from collections.abc import Iterable
26
+ from typing import Any
27
+
28
+ from ..errors import HookbaseError
29
+
30
+ # Params models serialize through `to_camel`, but `populate_by_name` means a
31
+ # caller may also use the snake_case field name, and a caller passing a plain
32
+ # dict can put that spelling straight on the wire.
33
+ _CAMEL_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
34
+
35
+ # Slug derivation mirrors `deriveDestinationSlug` in
36
+ # sdk/node-sdk/src/resources/wire.ts step for step, so all four SDKs derive
37
+ # byte-identical slugs from the same name. Keep the two in sync.
38
+
39
+ _SLUG_SEPARATORS = re.compile(r"[^a-z0-9]+")
40
+ _SLUG_EDGE_HYPHENS = re.compile(r"^-+|-+$")
41
+ _SLUG_TRAILING_HYPHENS = re.compile(r"-+$")
42
+ SLUG_MAX_LENGTH = 50
43
+
44
+ # Which characters count as combining marks is a function of the Unicode version the *runtime*
45
+ # ships, and the four SDKs' runtimes do not agree: CPython 3.12's `unicodedata` is Unicode 15.0,
46
+ # while the reference implementation's `\p{Mn}` (V8) is a version ahead. Reading the category alone
47
+ # therefore makes the same name slug differently depending on which SDK created the destination,
48
+ # which is the thing this derivation exists to prevent.
49
+ #
50
+ # These two tables close that gap, generated from the reference runtime's own `\p{Mn}` compared
51
+ # against Unicode 15.0's. Both self-heal: once this interpreter's tables cover the additions, the
52
+ # set is redundant but harmless, and `test_slug_contract.py` says so when that happens.
53
+ #
54
+ # 75 code points in 21 ranges, the same table `slugMarkAdditions` carries in
55
+ # sdk/go-sdk/slug_fold.go. Change one and you change both.
56
+ _SLUG_MARK_ADDITIONS: frozenset[str] = frozenset(
57
+ chr(cp)
58
+ for lo, hi in (
59
+ ("\u0897", "\u0897"),
60
+ ("\u1acf", "\u1add"),
61
+ ("\u1ae0", "\u1aeb"),
62
+ ("\U00010d69", "\U00010d6d"),
63
+ ("\U00010efa", "\U00010efc"),
64
+ ("\U000113bb", "\U000113c0"),
65
+ ("\U000113ce", "\U000113ce"),
66
+ ("\U000113d0", "\U000113d0"),
67
+ ("\U000113d2", "\U000113d2"),
68
+ ("\U000113e1", "\U000113e2"),
69
+ ("\U00011b60", "\U00011b60"),
70
+ ("\U00011b62", "\U00011b64"),
71
+ ("\U00011b66", "\U00011b66"),
72
+ ("\U00011f5a", "\U00011f5a"),
73
+ ("\U0001611e", "\U00016129"),
74
+ ("\U0001612d", "\U0001612f"),
75
+ ("\U0001e5ee", "\U0001e5ef"),
76
+ ("\U0001e6e3", "\U0001e6e3"),
77
+ ("\U0001e6e6", "\U0001e6e6"),
78
+ ("\U0001e6ee", "\U0001e6ef"),
79
+ ("\U0001e6f5", "\U0001e6f5"),
80
+ )
81
+ for cp in range(ord(lo), ord(hi) + 1)
82
+ )
83
+
84
+ # The correction in the other direction: AHOM CONSONANT SIGN MEDIAL RA was Mn in Unicode 15.0 and
85
+ # is Mc (a *spacing* mark) from 15.1, so the reference does not strip it and neither may this
86
+ # SDK -- it has to separate, the way any other non-alphanumeric does.
87
+ _SLUG_MARK_RECLASSIFIED = "\U0001171e"
88
+
89
+
90
+ # The same version skew, one layer down: NFKD itself. These characters are unassigned in Unicode
91
+ # 15.0, so this interpreter leaves them alone and they become hyphen separators, while the
92
+ # reference runtime decomposes them to ASCII letters and digits. Folding them first makes NFKD's
93
+ # answer the same on both. Every mapping is the reference's own NFKD output, not a guess at the
94
+ # character's meaning; a later interpreter that knows them decomposes them identically, so this
95
+ # stays a no-op rather than a divergence of its own.
96
+ #
97
+ # U+A7F1 Latin Extended-D, decomposes to "S"
98
+ # U+1CCD6 - U+1CCF9 36 contiguous additions in Symbols for Legacy Computing Supplement,
99
+ # decomposing to A-Z and then 0-9 in order
100
+ _SLUG_FOLD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
101
+ _SLUG_FOLD_ADDITIONS = {
102
+ 0xA7F1: "S",
103
+ **{0x1CCD6 + offset: char for offset, char in enumerate(_SLUG_FOLD_ALPHABET)},
104
+ }
105
+
106
+
107
+ def _is_slug_mark(char: str) -> bool:
108
+ """Whether `char` is a combining mark the slug fold drops. See the tables above."""
109
+ if char == _SLUG_MARK_RECLASSIFIED:
110
+ return False
111
+ return unicodedata.category(char) == "Mn" or char in _SLUG_MARK_ADDITIONS
112
+
113
+
114
+ def dump(params: Any) -> dict[str, Any]:
115
+ """Serialize a params model into a wire body, or copy a caller's dict.
116
+
117
+ The copy matters: these helpers mutate the body in place and callers hand
118
+ us dicts they may reuse.
119
+ """
120
+ if hasattr(params, "model_dump"):
121
+ body: dict[str, Any] = params.model_dump(by_alias=True, exclude_none=True)
122
+ return body
123
+ return dict(params or {})
124
+
125
+
126
+ def _spellings(key: str) -> tuple[str, ...]:
127
+ """Every spelling of one wire key a caller may have used."""
128
+ snake = _CAMEL_BOUNDARY.sub("_", key).lower()
129
+ return (key,) if snake == key else (key, snake)
130
+
131
+
132
+ def drop(body: dict[str, Any], *keys: str) -> None:
133
+ """Remove keys the API never accepted."""
134
+ for key in keys:
135
+ for spelling in _spellings(key):
136
+ body.pop(spelling, None)
137
+
138
+
139
+ def canonicalize(body: dict[str, Any], *keys: str) -> None:
140
+ """Move any snake_case spelling of `keys` onto its camelCase wire key."""
141
+ for key in keys:
142
+ for spelling in _spellings(key)[1:]:
143
+ if spelling in body:
144
+ body.setdefault(key, body.pop(spelling))
145
+
146
+
147
+ def rename(body: dict[str, Any], old: str, new: str) -> None:
148
+ """Send `old`'s value under `new`; an explicit `new` wins over `old`."""
149
+ canonicalize(body, new)
150
+ value: Any = None
151
+ found = False
152
+ for spelling in _spellings(old):
153
+ if spelling in body:
154
+ if not found:
155
+ value = body[spelling]
156
+ found = True
157
+ del body[spelling]
158
+ if found and new not in body:
159
+ body[new] = value
160
+
161
+
162
+ def apply_clear(body: dict[str, Any], allowed: tuple[str, ...], what: str) -> None:
163
+ """Turn a params model's `clear` list into explicit JSON nulls on the body.
164
+
165
+ Every optional field on every params model is `X | None = None`, and `dump`
166
+ excludes `None` -- so `None` means "leave this setting alone" and there is
167
+ nothing left to mean "reset it", even where the API accepts an explicit
168
+ null. A caller who had set a custom retry schedule could not remove it.
169
+
170
+ `clear` closes that: naming a field puts `null` on the wire for it. Only
171
+ the fields in `allowed` are accepted, and a field both set and cleared is
172
+ an error rather than a silent choice between the two. `clear` itself never
173
+ reaches the API.
174
+ """
175
+ requested: Any = None
176
+ for spelling in _spellings("clear"):
177
+ if spelling in body:
178
+ requested = body.pop(spelling)
179
+
180
+ if requested is None:
181
+ return
182
+ if isinstance(requested, str) or not isinstance(requested, Iterable):
183
+ raise HookbaseError(
184
+ f"`clear` takes a list of {what} field names, not {type(requested).__name__}."
185
+ )
186
+
187
+ for field in requested:
188
+ key = _CAMEL_BOUNDARY.sub("_", str(field)).lower()
189
+ wire = {_CAMEL_BOUNDARY.sub("_", name).lower(): name for name in allowed}.get(key)
190
+ if wire is None:
191
+ raise HookbaseError(
192
+ f"`clear` names {str(field)!r}, which is not one of the {what} fields the API "
193
+ f"accepts a null for. It accepts: {', '.join(sorted(allowed))}."
194
+ )
195
+ if wire in body:
196
+ raise HookbaseError(
197
+ f"`clear` names {str(field)!r} but that field is also set; clear it or set it, "
198
+ "not both."
199
+ )
200
+ body[wire] = None
201
+
202
+
203
+ def headers_to_pairs(body: dict[str, Any]) -> None:
204
+ """Convert endpoint `headers` from a mapping to the array the API takes.
205
+
206
+ `POST`/`PATCH /api/webhook-endpoints` validate headers as
207
+ `z.array({name, value}).max(10)`, so the mapping the SDK has always typed
208
+ here is rejected outright — a 400 today rather than a silently stripped
209
+ field. A mapping is converted in insertion order; an array a caller built
210
+ themselves passes through untouched.
211
+ """
212
+ value = body.get("headers")
213
+ if isinstance(value, dict):
214
+ body["headers"] = [{"name": str(name), "value": str(v)} for name, v in value.items()]
215
+
216
+
217
+ def derive_destination_slug(name: str) -> str:
218
+ """Derive a slug the API accepts (`^[a-z0-9-]+$`, max 50) from a name.
219
+
220
+ The API requires `slug` on create; this SDK has always had it optional, so
221
+ every call that left it out was a 400. Deriving one keeps those calls
222
+ working. Accents are folded first, so `Café EU` gives `cafe-eu` rather than
223
+ `caf-eu`.
224
+
225
+ Every step mirrors `deriveDestinationSlug` in the Node SDK
226
+ (sdk/node-sdk/src/resources/wire.ts) so all four SDKs agree byte for byte:
227
+ NFKD normalize, drop the combining marks the decomposition leaves behind,
228
+ lowercase, collapse each run of non-alphanumerics to one hyphen, trim the
229
+ ends, then cut to 50 characters. The cut is plain and may land mid-word —
230
+ that is what keeps the implementations identical, so do not trim back to a
231
+ word boundary — and can leave a trailing hyphen to remove.
232
+
233
+ Raises:
234
+ HookbaseError: if the name has no alphanumeric characters to derive
235
+ from, rather than sending a slug the API would reject.
236
+ """
237
+ # Drop the whole Mn category, not just the U+0300-U+036F block: a mark outside that block
238
+ # (Arabic, Hebrew, Devanagari) would otherwise survive to become a hyphen separator, and the
239
+ # same name would slug differently depending on which SDK created the destination.
240
+ # `_is_slug_mark` rather than the category alone, because the category answer depends on this
241
+ # interpreter's Unicode version -- see the tables above it.
242
+ decomposed = unicodedata.normalize("NFKD", name.translate(_SLUG_FOLD_ADDITIONS))
243
+ folded = "".join(ch for ch in decomposed if not _is_slug_mark(ch)).lower()
244
+ slug = _SLUG_EDGE_HYPHENS.sub("", _SLUG_SEPARATORS.sub("-", folded))
245
+ slug = _SLUG_TRAILING_HYPHENS.sub("", slug[:SLUG_MAX_LENGTH])
246
+
247
+ if not slug:
248
+ raise HookbaseError(
249
+ "Cannot derive a destination slug from name "
250
+ f"{json.dumps(name, ensure_ascii=False)}: pass "
251
+ "`slug` explicitly, as a string matching ^[a-z0-9-]+$ (max 50 characters)."
252
+ )
253
+
254
+ return slug
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import model_validator
6
+
7
+ from . import _wire
8
+ from ._base import HookbaseModel
9
+
10
+
11
+ class Application(HookbaseModel):
12
+ id: str
13
+ name: str
14
+ organization_id: str
15
+ external_id: str = ""
16
+ metadata: dict[str, Any] | None = None
17
+ created_at: str = ""
18
+ updated_at: str = ""
19
+
20
+ uid: str = ""
21
+ """Deprecated: the API calls this `externalId`; read `external_id` instead.
22
+ Kept in step with it so existing attribute access keeps working."""
23
+
24
+ @model_validator(mode="before")
25
+ @classmethod
26
+ def _mirror_external_id(cls, data: Any) -> Any:
27
+ """Keep `uid` and `external_id` showing the same value.
28
+
29
+ Application responses carry the field as `externalId`; `uid` is only
30
+ the SDK's older name for it, so populate both from whichever spelling
31
+ the response used. `externalId` is null for applications created
32
+ without one, so fall back to the empty string both fields default to.
33
+ """
34
+ if not isinstance(data, dict):
35
+ return data
36
+ value = data.get("externalId")
37
+ if value is None:
38
+ value = data.get("external_id")
39
+ if value is None:
40
+ value = data.get("uid")
41
+ return {**data, "externalId": value or "", "uid": value or ""}
42
+
43
+
44
+ class CreateApplicationParams(HookbaseModel):
45
+ name: str
46
+ external_id: str | None = None
47
+ metadata: dict[str, Any] | None = None
48
+
49
+ uid: str | None = None
50
+ """Deprecated: renamed to `external_id`, which is the only name the API
51
+ accepts. The value is sent under that name, and `external_id` wins if both
52
+ are set."""
53
+
54
+
55
+ class UpdateApplicationParams(HookbaseModel):
56
+ name: str | None = None
57
+ metadata: dict[str, Any] | None = None
58
+
59
+
60
+ def application_body(params: CreateApplicationParams | dict[str, Any]) -> dict[str, Any]:
61
+ """Build the request body for an application create or upsert.
62
+
63
+ The API has always called the caller's own identifier `externalId`; the SDK
64
+ sent `uid`, which `z.object()` stripped, so every application the Python
65
+ SDK created came back with no external ID at all. Map the old name onto the
66
+ new one the way the Node SDK already does.
67
+ """
68
+ body = _wire.dump(params)
69
+ _wire.rename(body, "uid", "externalId")
70
+ return body
@@ -5,6 +5,7 @@ from typing import Any, Literal
5
5
 
6
6
  from pydantic import Field, field_validator, model_validator
7
7
 
8
+ from . import _wire
8
9
  from ._base import HookbaseModel
9
10
 
10
11
  HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
@@ -72,16 +73,13 @@ class Destination(HookbaseModel):
72
73
  organization_id: str | None = None
73
74
  name: str
74
75
  slug: str
75
- description: str | None = None
76
76
  type: DestinationType = "http"
77
77
  url: str = ""
78
78
  method: HttpMethod = "POST"
79
79
  headers: dict[str, str] | None = None
80
80
  auth_type: str | None = "none"
81
81
  auth_config: dict[str, Any] | None = None
82
- timeout: int = 30
83
- retry_count: int = 3
84
- retry_interval: int = 60
82
+ timeout_ms: int = 30000
85
83
  throttle: Throttle | None = None
86
84
  is_active: bool = True
87
85
  use_static_ip: bool = True
@@ -94,6 +92,35 @@ class Destination(HookbaseModel):
94
92
  created_at: str = ""
95
93
  updated_at: str = ""
96
94
 
95
+ # Deprecated response fields. The API returns none of these — destinations
96
+ # have no description or per-destination retry columns, and the request
97
+ # timeout comes back as `timeoutMs` — so `description`/`retry_count`/
98
+ # `retry_interval` are always the defaults below. Kept so existing
99
+ # attribute access keeps working.
100
+ description: str | None = None
101
+ """Deprecated: destinations have no description column."""
102
+ timeout: int = 30000
103
+ """Deprecated: read `timeout_ms` instead. Mirrors it (milliseconds) when
104
+ the response carries no `timeout` of its own."""
105
+ retry_count: int = 3
106
+ """Deprecated: retries are configured per route, not per destination."""
107
+ retry_interval: int = 60
108
+ """Deprecated: retries are configured per route, not per destination."""
109
+
110
+ @model_validator(mode="before")
111
+ @classmethod
112
+ def _mirror_timeout(cls, data: Any) -> Any:
113
+ """Keep the deprecated `timeout` field showing the real timeout.
114
+
115
+ The API answers with `timeoutMs`; `timeout` is only the old name for
116
+ the same number, so populate it from `timeoutMs` when the response has
117
+ no `timeout` of its own.
118
+ """
119
+ if isinstance(data, dict) and "timeoutMs" in data and "timeout" not in data:
120
+ data = dict(data)
121
+ data["timeout"] = data["timeoutMs"]
122
+ return data
123
+
97
124
  @model_validator(mode="before")
98
125
  @classmethod
99
126
  def _normalize_throttle(cls, data: Any) -> Any:
@@ -169,16 +196,23 @@ class Destination(HookbaseModel):
169
196
  class CreateDestinationParams(HookbaseModel):
170
197
  name: str
171
198
  slug: str | None = None
172
- description: str | None = None
199
+ """URL-safe identifier, `^[a-z0-9-]+$`, max 50 characters.
200
+
201
+ Required by the API. Omit it and one is derived from `name` rather than
202
+ letting the request 400 — pass it explicitly whenever the slug matters,
203
+ since it is part of how the destination is addressed and cannot be changed
204
+ afterwards.
205
+ """
173
206
  type: DestinationType | None = None
174
207
  url: str | None = None
175
208
  method: HttpMethod | None = None
176
209
  headers: dict[str, str] | None = None
210
+ """Sent as a mapping — the shape the destinations API takes, unlike
211
+ endpoint headers."""
177
212
  auth_type: AuthType | None = None
178
213
  auth_config: dict[str, Any] | None = None
179
- timeout: int | None = None
180
- retry_count: int | None = None
181
- retry_interval: int | None = None
214
+ timeout_ms: int | None = None
215
+ """Request timeout in milliseconds, 1000-60000. The API defaults to 30000."""
182
216
  throttle: Throttle | None = None
183
217
  config: dict[str, Any] | None = None
184
218
  field_mapping: list[FieldMapping] | None = None
@@ -186,18 +220,36 @@ class CreateDestinationParams(HookbaseModel):
186
220
  batch_size: int | None = None
187
221
  batch_window_seconds: int | None = None
188
222
 
223
+ # Deprecated fields; see `create_destination_body` for what each one does
224
+ # on the wire.
225
+ description: str | None = None
226
+ """Deprecated: never accepted by the API and no longer sent; destinations
227
+ have no description column."""
228
+ timeout: int | None = None
229
+ """Deprecated: renamed to `timeout_ms`. The value is sent under that name
230
+ unchanged, and `timeout_ms` wins if both are set.
231
+
232
+ Milliseconds, 1000-60000, as everywhere else in this SDK. The value was not
233
+ reaching the API before, so a call that passed seconds here landed on the
234
+ 30000 default and now 400s instead; multiply by 1000.
235
+ """
236
+ retry_count: int | None = None
237
+ """Deprecated: never accepted by the API and no longer sent; retries are
238
+ configured per route, not per destination."""
239
+ retry_interval: int | None = None
240
+ """Deprecated: never accepted by the API and no longer sent; retries are
241
+ configured per route, not per destination."""
242
+
189
243
 
190
244
  class UpdateDestinationParams(HookbaseModel):
191
245
  name: str | None = None
192
- description: str | None = None
193
246
  url: str | None = None
194
247
  method: HttpMethod | None = None
195
248
  headers: dict[str, str] | None = None
196
249
  auth_type: AuthType | None = None
197
250
  auth_config: dict[str, Any] | None = None
198
- timeout: int | None = None
199
- retry_count: int | None = None
200
- retry_interval: int | None = None
251
+ timeout_ms: int | None = None
252
+ """Request timeout in milliseconds, 1000-60000."""
201
253
  throttle: Throttle | None = None
202
254
  is_active: bool | None = None
203
255
  config: dict[str, Any] | None = None
@@ -206,6 +258,62 @@ class UpdateDestinationParams(HookbaseModel):
206
258
  batch_size: int | None = None
207
259
  batch_window_seconds: int | None = None
208
260
 
261
+ # Deprecated fields; see `CreateDestinationParams` for each one's status.
262
+ description: str | None = None
263
+ """Deprecated: never accepted by the API and no longer sent."""
264
+ timeout: int | None = None
265
+ """Deprecated: renamed to `timeout_ms`. The value is sent under that name
266
+ unchanged, and `timeout_ms` wins if both are set.
267
+
268
+ Milliseconds, 1000-60000, as everywhere else in this SDK. The value was not
269
+ reaching the API before, so a call that passed seconds here landed on the
270
+ 30000 default and now 400s instead; multiply by 1000.
271
+ """
272
+ retry_count: int | None = None
273
+ """Deprecated: never accepted by the API and no longer sent."""
274
+ retry_interval: int | None = None
275
+ """Deprecated: never accepted by the API and no longer sent."""
276
+
277
+
278
+ def _strip_deprecated(body: dict[str, Any]) -> None:
279
+ """Apply the rules shared by destination create and update bodies."""
280
+ _wire.drop(body, "description", "retryCount", "retryInterval")
281
+ _wire.rename(body, "timeout", "timeoutMs")
282
+
283
+
284
+ def create_destination_body(
285
+ params: CreateDestinationParams | dict[str, Any],
286
+ ) -> dict[str, Any]:
287
+ """Build the request body for `POST /api/destinations`.
288
+
289
+ `slug` is required by the API (`^[a-z0-9-]+$`, max 50) but has always been
290
+ optional here, so a request that omits it 400s. Derive one from `name`
291
+ instead of letting that happen; an explicit slug is never rewritten.
292
+ """
293
+ body = _wire.dump(params)
294
+ _strip_deprecated(body)
295
+ slug = body.get("slug")
296
+ if slug is None or (isinstance(slug, str) and not slug.strip()):
297
+ name = body.get("name")
298
+ # A missing or non-string `name` is the API's own `name` error to
299
+ # report, not a slug that could not be derived.
300
+ if isinstance(name, str):
301
+ body["slug"] = _wire.derive_destination_slug(name)
302
+ return body
303
+
304
+
305
+ def update_destination_body(
306
+ params: UpdateDestinationParams | dict[str, Any],
307
+ ) -> dict[str, Any]:
308
+ """Build the request body for `PATCH /api/destinations/:id`.
309
+
310
+ Unlike create, the API takes no `slug` here — a destination's slug cannot
311
+ be changed after it is created.
312
+ """
313
+ body = _wire.dump(params)
314
+ _strip_deprecated(body)
315
+ return body
316
+
209
317
 
210
318
  class TestResult(HookbaseModel):
211
319
  success: bool