hookbase 3.0.0__tar.gz → 3.2.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.0.0 → hookbase-3.2.0}/PKG-INFO +32 -6
  2. {hookbase-3.0.0 → hookbase-3.2.0}/README.md +31 -5
  3. {hookbase-3.0.0 → hookbase-3.2.0}/examples/async_usage.py +1 -1
  4. {hookbase-3.0.0 → hookbase-3.2.0}/examples/basic_inbound.py +1 -1
  5. {hookbase-3.0.0 → hookbase-3.2.0}/examples/send_webhooks.py +1 -1
  6. hookbase-3.2.0/src/hookbase/_version.py +1 -0
  7. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/client.py +8 -0
  8. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/__init__.py +35 -0
  9. hookbase-3.2.0/src/hookbase/models/_wire.py +254 -0
  10. hookbase-3.2.0/src/hookbase/models/applications.py +70 -0
  11. hookbase-3.2.0/src/hookbase/models/catalog.py +120 -0
  12. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/destinations.py +120 -12
  13. hookbase-3.2.0/src/hookbase/models/endpoints.py +275 -0
  14. hookbase-3.2.0/src/hookbase/models/sources.py +283 -0
  15. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/__init__.py +2 -0
  16. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/_base.py +8 -2
  17. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/applications.py +38 -6
  18. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/destinations.py +7 -5
  19. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/endpoints.py +18 -7
  20. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/events.py +20 -0
  21. hookbase-3.2.0/src/hookbase/resources/providers.py +54 -0
  22. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/sources.py +82 -2
  23. {hookbase-3.0.0 → hookbase-3.2.0}/tests/conftest.py +6 -0
  24. hookbase-3.2.0/tests/resources/test_applications.py +138 -0
  25. {hookbase-3.0.0 → hookbase-3.2.0}/tests/resources/test_destinations.py +196 -7
  26. hookbase-3.2.0/tests/resources/test_endpoints.py +432 -0
  27. hookbase-3.2.0/tests/resources/test_source_contract.py +302 -0
  28. {hookbase-3.0.0 → hookbase-3.2.0}/tests/resources/test_source_fields.py +55 -2
  29. hookbase-3.2.0/tests/test_slug_contract.py +143 -0
  30. hookbase-3.0.0/src/hookbase/_version.py +0 -1
  31. hookbase-3.0.0/src/hookbase/models/applications.py +0 -26
  32. hookbase-3.0.0/src/hookbase/models/endpoints.py +0 -79
  33. hookbase-3.0.0/src/hookbase/models/sources.py +0 -180
  34. hookbase-3.0.0/tests/resources/test_applications.py +0 -77
  35. {hookbase-3.0.0 → hookbase-3.2.0}/.github/workflows/python-publish.yml +0 -0
  36. {hookbase-3.0.0 → hookbase-3.2.0}/.gitignore +0 -0
  37. {hookbase-3.0.0 → hookbase-3.2.0}/LICENSE +0 -0
  38. {hookbase-3.0.0 → hookbase-3.2.0}/examples/error_handling.py +0 -0
  39. {hookbase-3.0.0 → hookbase-3.2.0}/examples/verify_webhooks.py +0 -0
  40. {hookbase-3.0.0 → hookbase-3.2.0}/pyproject.toml +0 -0
  41. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/__init__.py +0 -0
  42. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/_client.py +0 -0
  43. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/_constants.py +0 -0
  44. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/_pagination.py +0 -0
  45. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/errors.py +0 -0
  46. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/_base.py +0 -0
  47. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/analytics.py +0 -0
  48. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/api_keys.py +0 -0
  49. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/common.py +0 -0
  50. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/cron_jobs.py +0 -0
  51. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/deliveries.py +0 -0
  52. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/dlq.py +0 -0
  53. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/event_types.py +0 -0
  54. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/events.py +0 -0
  55. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/filters.py +0 -0
  56. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/messages.py +0 -0
  57. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/organizations.py +0 -0
  58. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/portal_tokens.py +0 -0
  59. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/routes.py +0 -0
  60. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/schemas.py +0 -0
  61. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/subscriptions.py +0 -0
  62. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/transforms.py +0 -0
  63. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/models/tunnels.py +0 -0
  64. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/py.typed +0 -0
  65. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/analytics.py +0 -0
  66. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/api_keys.py +0 -0
  67. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/cron_jobs.py +0 -0
  68. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/deliveries.py +0 -0
  69. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/dlq.py +0 -0
  70. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/event_types.py +0 -0
  71. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/filters.py +0 -0
  72. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/message_log.py +0 -0
  73. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/messages.py +0 -0
  74. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/organizations.py +0 -0
  75. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/portal_tokens.py +0 -0
  76. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/routes.py +0 -0
  77. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/schemas.py +0 -0
  78. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/subscriptions.py +0 -0
  79. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/transforms.py +0 -0
  80. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/resources/tunnels.py +0 -0
  81. {hookbase-3.0.0 → hookbase-3.2.0}/src/hookbase/webhook.py +0 -0
  82. {hookbase-3.0.0 → hookbase-3.2.0}/tests/__init__.py +0 -0
  83. {hookbase-3.0.0 → hookbase-3.2.0}/tests/resources/__init__.py +0 -0
  84. {hookbase-3.0.0 → hookbase-3.2.0}/tests/resources/test_cron_jobs.py +0 -0
  85. {hookbase-3.0.0 → hookbase-3.2.0}/tests/resources/test_dlq.py +0 -0
  86. {hookbase-3.0.0 → hookbase-3.2.0}/tests/resources/test_messages.py +0 -0
  87. {hookbase-3.0.0 → hookbase-3.2.0}/tests/resources/test_sources.py +0 -0
  88. {hookbase-3.0.0 → hookbase-3.2.0}/tests/test_client.py +0 -0
  89. {hookbase-3.0.0 → hookbase-3.2.0}/tests/test_errors.py +0 -0
  90. {hookbase-3.0.0 → hookbase-3.2.0}/tests/test_pagination.py +0 -0
  91. {hookbase-3.0.0 → hookbase-3.2.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.2.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
@@ -98,7 +98,26 @@ client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"}
98
98
  client.sources.get("src_id")
99
99
  client.sources.update("src_id", {"name": "Updated"})
100
100
  client.sources.delete("src_id")
101
- client.sources.rotate_secret("src_id")
101
+ client.sources.rotate_secret("src_id") # 400 for vendor-issued/public-key/built-in secrets
102
+ client.sources.reveal_secret("src_id")
103
+
104
+ # A provider that verifies with no secret: signing_secret comes back None
105
+ wise = client.sources.create({"name": "Wise", "slug": "wise", "provider": "wise"})
106
+ assert wise.verifies_without_secret and wise.signing_secret is None
107
+
108
+ # WhatsApp (Meta) sources also carry a verify token
109
+ wa = client.sources.create({"name": "WhatsApp", "slug": "whatsapp", "provider": "whatsapp"})
110
+ print(wa.verify_token)
111
+ client.sources.rotate_verify_token(wa.id)
112
+
113
+ # Notion parks its verification token until you adopt it
114
+ pending = client.sources.get_pending_verification("notion")
115
+ client.sources.adopt_pending_verification("notion")
116
+
117
+ # Provider catalog: which secret each provider needs (public, no auth needed)
118
+ for p in client.providers.list(category="payments").providers:
119
+ print(p.hookbase_provider, p.signature_verification and p.signature_verification.secret_source)
120
+ client.providers.get("stripe").signature_verification
102
121
 
103
122
  # Destinations
104
123
  client.destinations.list()
@@ -111,7 +130,7 @@ client.routes.get_circuit_status("route_id")
111
130
  client.routes.reset_circuit("route_id")
112
131
 
113
132
  # Events & Deliveries
114
- client.events.list(source_id="src_id", from_date="2024-01-01")
133
+ client.events.list(source_id="src_id", from_date="2024-01-01") # id or slug; unknown -> 404
115
134
  client.events.get("evt_id") # Includes payload and deliveries
116
135
  client.deliveries.replay("del_id")
117
136
  client.deliveries.bulk_replay(["del_1", "del_2"])
@@ -129,13 +148,17 @@ client.schemas.create({"name": "OrderSchema", "jsonSchema": {"type": "object", .
129
148
 
130
149
  ```python
131
150
  # Applications
132
- client.outbound.applications.create({"name": "Acme", "uid": "cust_123"})
151
+ client.outbound.applications.create({"name": "Acme", "externalId": "cust_123"})
133
152
  client.outbound.applications.get_by_external_id("cust_123")
134
153
 
135
154
  # Endpoints
136
155
  client.outbound.endpoints.create("app_id", {"url": "https://..."})
137
156
  client.outbound.endpoints.rotate_secret("ep_id", grace_period=3600)
138
157
 
158
+ # An unset field is left alone by an update, so `None` cannot also mean "reset
159
+ # this". Name the setting in `clear` to send an explicit null for it:
160
+ client.outbound.endpoints.update("ep_id", {"clear": ["retryDelays"]})
161
+
139
162
  # Event Types & Subscriptions
140
163
  client.outbound.event_types.create({"name": "order.created", "category": "orders"})
141
164
  client.outbound.subscriptions.create({"endpointId": "ep_id", "eventTypeId": "et_id"})
@@ -248,11 +271,14 @@ All responses are typed Pydantic models with camelCase alias support:
248
271
  from hookbase.models import Source, CreateSourceParams
249
272
 
250
273
  # Use Pydantic models for type safety
251
- params = CreateSourceParams(name="GitHub", provider="github")
274
+ params = CreateSourceParams(name="GitHub", slug="github", provider="github")
252
275
  source = client.sources.create(params)
253
276
 
254
277
  # Or use plain dicts
255
- source = client.sources.create({"name": "GitHub", "provider": "github"})
278
+ source = client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"})
279
+
280
+ # `provider` is any string (hookbase.models.KNOWN_SOURCE_PROVIDERS lists today's values), so a
281
+ # source with a provider newer than this package still parses.
256
282
  ```
257
283
 
258
284
  ## Development
@@ -63,7 +63,26 @@ client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"}
63
63
  client.sources.get("src_id")
64
64
  client.sources.update("src_id", {"name": "Updated"})
65
65
  client.sources.delete("src_id")
66
- client.sources.rotate_secret("src_id")
66
+ client.sources.rotate_secret("src_id") # 400 for vendor-issued/public-key/built-in secrets
67
+ client.sources.reveal_secret("src_id")
68
+
69
+ # A provider that verifies with no secret: signing_secret comes back None
70
+ wise = client.sources.create({"name": "Wise", "slug": "wise", "provider": "wise"})
71
+ assert wise.verifies_without_secret and wise.signing_secret is None
72
+
73
+ # WhatsApp (Meta) sources also carry a verify token
74
+ wa = client.sources.create({"name": "WhatsApp", "slug": "whatsapp", "provider": "whatsapp"})
75
+ print(wa.verify_token)
76
+ client.sources.rotate_verify_token(wa.id)
77
+
78
+ # Notion parks its verification token until you adopt it
79
+ pending = client.sources.get_pending_verification("notion")
80
+ client.sources.adopt_pending_verification("notion")
81
+
82
+ # Provider catalog: which secret each provider needs (public, no auth needed)
83
+ for p in client.providers.list(category="payments").providers:
84
+ print(p.hookbase_provider, p.signature_verification and p.signature_verification.secret_source)
85
+ client.providers.get("stripe").signature_verification
67
86
 
68
87
  # Destinations
69
88
  client.destinations.list()
@@ -76,7 +95,7 @@ client.routes.get_circuit_status("route_id")
76
95
  client.routes.reset_circuit("route_id")
77
96
 
78
97
  # Events & Deliveries
79
- client.events.list(source_id="src_id", from_date="2024-01-01")
98
+ client.events.list(source_id="src_id", from_date="2024-01-01") # id or slug; unknown -> 404
80
99
  client.events.get("evt_id") # Includes payload and deliveries
81
100
  client.deliveries.replay("del_id")
82
101
  client.deliveries.bulk_replay(["del_1", "del_2"])
@@ -94,13 +113,17 @@ client.schemas.create({"name": "OrderSchema", "jsonSchema": {"type": "object", .
94
113
 
95
114
  ```python
96
115
  # Applications
97
- client.outbound.applications.create({"name": "Acme", "uid": "cust_123"})
116
+ client.outbound.applications.create({"name": "Acme", "externalId": "cust_123"})
98
117
  client.outbound.applications.get_by_external_id("cust_123")
99
118
 
100
119
  # Endpoints
101
120
  client.outbound.endpoints.create("app_id", {"url": "https://..."})
102
121
  client.outbound.endpoints.rotate_secret("ep_id", grace_period=3600)
103
122
 
123
+ # An unset field is left alone by an update, so `None` cannot also mean "reset
124
+ # this". Name the setting in `clear` to send an explicit null for it:
125
+ client.outbound.endpoints.update("ep_id", {"clear": ["retryDelays"]})
126
+
104
127
  # Event Types & Subscriptions
105
128
  client.outbound.event_types.create({"name": "order.created", "category": "orders"})
106
129
  client.outbound.subscriptions.create({"endpointId": "ep_id", "eventTypeId": "et_id"})
@@ -213,11 +236,14 @@ All responses are typed Pydantic models with camelCase alias support:
213
236
  from hookbase.models import Source, CreateSourceParams
214
237
 
215
238
  # Use Pydantic models for type safety
216
- params = CreateSourceParams(name="GitHub", provider="github")
239
+ params = CreateSourceParams(name="GitHub", slug="github", provider="github")
217
240
  source = client.sources.create(params)
218
241
 
219
242
  # Or use plain dicts
220
- source = client.sources.create({"name": "GitHub", "provider": "github"})
243
+ source = client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"})
244
+
245
+ # `provider` is any string (hookbase.models.KNOWN_SOURCE_PROVIDERS lists today's values), so a
246
+ # source with a provider newer than this package still parses.
221
247
  ```
222
248
 
223
249
  ## Development
@@ -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.2.0"
@@ -26,6 +26,7 @@ from .resources import (
26
26
  AsyncMessages,
27
27
  AsyncOrganizations,
28
28
  AsyncPortalTokens,
29
+ AsyncProviders,
29
30
  AsyncRoutes,
30
31
  AsyncSchemas,
31
32
  AsyncSources,
@@ -43,6 +44,7 @@ from .resources import (
43
44
  Messages,
44
45
  Organizations,
45
46
  PortalTokens,
47
+ Providers,
46
48
  Routes,
47
49
  Schemas,
48
50
  Sources,
@@ -118,6 +120,8 @@ class Hookbase:
118
120
  transforms: Transforms
119
121
  filters: Filters
120
122
  schemas: Schemas
123
+ #: The public provider catalog (what each provider signs with, which secret it needs).
124
+ providers: Providers
121
125
 
122
126
  # Outbound
123
127
  outbound: _OutboundNamespace
@@ -160,6 +164,7 @@ class Hookbase:
160
164
  self.transforms = Transforms(self._transport)
161
165
  self.filters = Filters(self._transport)
162
166
  self.schemas = Schemas(self._transport)
167
+ self.providers = Providers(self._transport)
163
168
 
164
169
  # Outbound namespace
165
170
  self.outbound = _OutboundNamespace(self._transport)
@@ -210,6 +215,8 @@ class AsyncHookbase:
210
215
  transforms: AsyncTransforms
211
216
  filters: AsyncFilters
212
217
  schemas: AsyncSchemas
218
+ #: The public provider catalog (what each provider signs with, which secret it needs).
219
+ providers: AsyncProviders
213
220
 
214
221
  # Outbound
215
222
  outbound: _AsyncOutboundNamespace
@@ -252,6 +259,7 @@ class AsyncHookbase:
252
259
  self.transforms = AsyncTransforms(self._transport)
253
260
  self.filters = AsyncFilters(self._transport)
254
261
  self.schemas = AsyncSchemas(self._transport)
262
+ self.providers = AsyncProviders(self._transport)
255
263
 
256
264
  # Outbound namespace
257
265
  self.outbound = _AsyncOutboundNamespace(self._transport)
@@ -1,6 +1,16 @@
1
1
  from .analytics import AnalyticsTimeline, DashboardData
2
2
  from .api_keys import ApiKey, ApiKeyWithSecret, CreateApiKeyParams
3
3
  from .applications import Application, CreateApplicationParams, UpdateApplicationParams
4
+ from .catalog import (
5
+ CatalogProvider,
6
+ CatalogProviderDetail,
7
+ ProviderCatalogList,
8
+ ProviderEvents,
9
+ ProviderEventType,
10
+ ProviderSignatureVerification,
11
+ ProviderSignatureVerificationSummary,
12
+ ProviderVerifyTokenInfo,
13
+ )
4
14
  from .common import BulkDeleteResult, ImportResult, ImportResultItem
5
15
  from .cron_jobs import (
6
16
  CreateCronGroupParams,
@@ -42,7 +52,11 @@ from .dlq import (
42
52
  DlqStats,
43
53
  )
44
54
  from .endpoints import (
55
+ CLEARABLE_ENDPOINT_FIELDS,
56
+ BackoffType,
57
+ ClearableEndpointField,
45
58
  CreateEndpointParams,
59
+ CustomHeader,
46
60
  EndpointStats,
47
61
  EndpointWithSecret,
48
62
  RotateSecretResult,
@@ -85,9 +99,13 @@ from .routes import (
85
99
  )
86
100
  from .schemas import CreateSchemaParams, Schema, SchemaValidationResult, UpdateSchemaParams
87
101
  from .sources import (
102
+ CLEARABLE_SOURCE_FIELDS,
103
+ KNOWN_SOURCE_PROVIDERS,
104
+ ClearableSourceField,
88
105
  CreateSourceParams,
89
106
  DedupStrategy,
90
107
  IpFilterMode,
108
+ PendingVerification,
91
109
  Source,
92
110
  SourceProvider,
93
111
  SourceWithSecret,
@@ -115,6 +133,15 @@ __all__ = [
115
133
  "Application",
116
134
  "CreateApplicationParams",
117
135
  "UpdateApplicationParams",
136
+ # Catalog
137
+ "CatalogProvider",
138
+ "CatalogProviderDetail",
139
+ "ProviderCatalogList",
140
+ "ProviderEventType",
141
+ "ProviderEvents",
142
+ "ProviderSignatureVerification",
143
+ "ProviderSignatureVerificationSummary",
144
+ "ProviderVerifyTokenInfo",
118
145
  # Common
119
146
  "BulkDeleteResult",
120
147
  "ImportResult",
@@ -155,7 +182,11 @@ __all__ = [
155
182
  "DlqRetryResult",
156
183
  "DlqStats",
157
184
  # Endpoints
185
+ "CLEARABLE_ENDPOINT_FIELDS",
186
+ "BackoffType",
187
+ "ClearableEndpointField",
158
188
  "CreateEndpointParams",
189
+ "CustomHeader",
159
190
  "EndpointStats",
160
191
  "EndpointWithSecret",
161
192
  "RotateSecretResult",
@@ -203,9 +234,13 @@ __all__ = [
203
234
  "SchemaValidationResult",
204
235
  "UpdateSchemaParams",
205
236
  # Sources
237
+ "CLEARABLE_SOURCE_FIELDS",
238
+ "KNOWN_SOURCE_PROVIDERS",
239
+ "ClearableSourceField",
206
240
  "CreateSourceParams",
207
241
  "DedupStrategy",
208
242
  "IpFilterMode",
243
+ "PendingVerification",
209
244
  "Source",
210
245
  "SourceProvider",
211
246
  "SourceWithSecret",
@@ -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