webhook-co 0.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.
- webhook_co-0.1.0/.gitignore +7 -0
- webhook_co-0.1.0/PKG-INFO +169 -0
- webhook_co-0.1.0/README.md +139 -0
- webhook_co-0.1.0/pyproject.toml +67 -0
- webhook_co-0.1.0/scripts/__init__.py +0 -0
- webhook_co-0.1.0/scripts/generate_models.py +129 -0
- webhook_co-0.1.0/src/webhook_co/__init__.py +90 -0
- webhook_co-0.1.0/src/webhook_co/_config.py +40 -0
- webhook_co-0.1.0/src/webhook_co/_errors.py +158 -0
- webhook_co-0.1.0/src/webhook_co/_generated/__init__.py +1 -0
- webhook_co-0.1.0/src/webhook_co/_generated/models.py +756 -0
- webhook_co-0.1.0/src/webhook_co/_http.py +211 -0
- webhook_co-0.1.0/src/webhook_co/_pagination.py +58 -0
- webhook_co-0.1.0/src/webhook_co/_query.py +23 -0
- webhook_co-0.1.0/src/webhook_co/_redaction.py +46 -0
- webhook_co-0.1.0/src/webhook_co/_retry.py +63 -0
- webhook_co-0.1.0/src/webhook_co/client.py +590 -0
- webhook_co-0.1.0/src/webhook_co/py.typed +0 -0
- webhook_co-0.1.0/tests/__init__.py +1 -0
- webhook_co-0.1.0/tests/test_client.py +470 -0
- webhook_co-0.1.0/tests/test_config.py +36 -0
- webhook_co-0.1.0/tests/test_coverage_extra.py +169 -0
- webhook_co-0.1.0/tests/test_errors.py +81 -0
- webhook_co-0.1.0/tests/test_generated_drift.py +17 -0
- webhook_co-0.1.0/tests/test_http.py +252 -0
- webhook_co-0.1.0/tests/test_pagination.py +49 -0
- webhook_co-0.1.0/tests/test_query.py +34 -0
- webhook_co-0.1.0/tests/test_redaction.py +62 -0
- webhook_co-0.1.0/tests/test_retry.py +59 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webhook-co
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The official Python SDK for the webhook.co API — a typed client with retries, cursor pagination, idempotency, and secret redaction.
|
|
5
|
+
Project-URL: Homepage, https://webhook.co
|
|
6
|
+
Project-URL: Repository, https://github.com/webhook-co/webhook
|
|
7
|
+
Project-URL: Issues, https://github.com/webhook-co/webhook/issues
|
|
8
|
+
Author: webhook.co
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: api-client,sdk,standard-webhooks,webhook,webhook.co,webhooks
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: pydantic>=2.7
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: black==26.5.1; extra == 'dev'
|
|
25
|
+
Requires-Dist: datamodel-code-generator==0.67.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: isort==8.0.1; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# webhook.co Python SDK
|
|
32
|
+
|
|
33
|
+
The official Python SDK for the [webhook.co](https://webhook.co) API. A typed client with the hardening
|
|
34
|
+
you'd otherwise hand-roll: bearer auth, bounded retries with jitter, cursor pagination, idempotency, and
|
|
35
|
+
secret redaction — with pydantic v2 models generated from the same OpenAPI contract the API is built on.
|
|
36
|
+
|
|
37
|
+
Python 3.10+. Built on `httpx`.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
pip install webhook-co
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import os
|
|
49
|
+
from webhook_co import WebhookClient
|
|
50
|
+
|
|
51
|
+
client = WebhookClient(api_key=os.environ["WEBHOOK_API_KEY"])
|
|
52
|
+
|
|
53
|
+
# Create an endpoint — the one-time ingest URL is returned once, so capture it now.
|
|
54
|
+
endpoint = client.endpoints.create(name="orders-prod")
|
|
55
|
+
print(endpoint.ingest_url)
|
|
56
|
+
|
|
57
|
+
# List events for that endpoint (auto-paginates).
|
|
58
|
+
for event in client.events.list(endpoint.id):
|
|
59
|
+
print(event.id, event.provider, event.verification_state)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The API key is a `whk_`-prefixed token from your dashboard. Keep it server-side — this SDK never prints
|
|
63
|
+
it, but it's still a credential.
|
|
64
|
+
|
|
65
|
+
Responses come back as validated pydantic models, so attributes are typed and snake_cased
|
|
66
|
+
(`endpoint.org_id`, `endpoint.created_at`).
|
|
67
|
+
|
|
68
|
+
## Pagination
|
|
69
|
+
|
|
70
|
+
List methods return a `Paginator` you can iterate directly — it follows the cursor for you:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
for endpoint in client.endpoints.list(name="prod"):
|
|
74
|
+
print(endpoint.id)
|
|
75
|
+
|
|
76
|
+
# Collect everything (careful with large result sets):
|
|
77
|
+
failed = client.deliveries.list(status=["failed"]).collect()
|
|
78
|
+
|
|
79
|
+
# One page at a time (e.g. to build your own UI):
|
|
80
|
+
page = client.endpoints.list_page(limit=50)
|
|
81
|
+
print(page.items, page.next_cursor)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Errors
|
|
85
|
+
|
|
86
|
+
Every failure is a `WebhookError` subclass, so you can narrow with `except` — no string matching:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from webhook_co import (
|
|
90
|
+
WebhookRateLimitError,
|
|
91
|
+
WebhookNotFoundError,
|
|
92
|
+
WebhookAuthenticationError,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
client.endpoints.get(endpoint_id)
|
|
97
|
+
except WebhookNotFoundError:
|
|
98
|
+
... # 404 — no such endpoint
|
|
99
|
+
except WebhookRateLimitError as err:
|
|
100
|
+
print(f"retry after {err.retry_after_s}s")
|
|
101
|
+
except WebhookAuthenticationError:
|
|
102
|
+
... # 401 — the key is invalid, expired, or revoked
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Each error carries `code` (a stable capability-error string), `status` (the HTTP status), and
|
|
106
|
+
`request_id` when the server sent one.
|
|
107
|
+
|
|
108
|
+
## Retries & idempotency
|
|
109
|
+
|
|
110
|
+
The client retries idempotent requests on transient failures (429/502/503/504 and network errors) with
|
|
111
|
+
capped exponential backoff and jitter, honouring `Retry-After`. It **never** blind-retries a
|
|
112
|
+
non-idempotent write — creating an endpoint, rotating a secret, or an un-keyed replay won't be sent twice
|
|
113
|
+
by the SDK. Replays carry an idempotency key (auto-generated if you don't pass one), so those are safe:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import uuid
|
|
117
|
+
|
|
118
|
+
client.events.replay(
|
|
119
|
+
event.id,
|
|
120
|
+
target={"kind": "destination", "destinationId": destination_id},
|
|
121
|
+
idempotency_key=str(uuid.uuid4()),
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Tune the budget per client:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
client = WebhookClient(api_key, max_retries=4, timeout_s=15) # defaults: 2 retries, 30s
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Payloads
|
|
132
|
+
|
|
133
|
+
`events.get_payload` decodes the wire envelope and hands you the exact bytes (length-checked, so a
|
|
134
|
+
truncated body raises rather than silently short-reading):
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
payload = client.events.get_payload(event.id)
|
|
138
|
+
print(payload.content_type, len(payload.body)) # payload.body is bytes
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Configuration
|
|
142
|
+
|
|
143
|
+
| Argument | Default | Notes |
|
|
144
|
+
| -------------- | ------------------------ | ------------------------------------------------------------ |
|
|
145
|
+
| `api_key` | — | Required. A `whk_` API key. |
|
|
146
|
+
| `base_url` | `https://api.webhook.co` | Must be https (loopback http allowed for self-host / dev). |
|
|
147
|
+
| `http_client` | a new `httpx.Client` | Pass your own for custom transports/proxies. |
|
|
148
|
+
| `max_retries` | `2` | Retries after the first attempt, idempotent requests only. |
|
|
149
|
+
| `timeout_s` | `30` | Per-request timeout (seconds), per httpx connect/read/write phase. |
|
|
150
|
+
| `refresh_auth` | — | Hook returning a rotated bearer on a 401 (OAuth flows). |
|
|
151
|
+
| `on_debug` | — | Redacted, single-line diagnostics — never the raw key. |
|
|
152
|
+
|
|
153
|
+
Use it as a context manager to close the underlying connection pool:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
with WebhookClient(api_key) as client:
|
|
157
|
+
client.whoami()
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## API surface
|
|
161
|
+
|
|
162
|
+
`endpoints` (list · list_page · get · create · delete · rotate · `provider_secrets` add/list/revoke) ·
|
|
163
|
+
`events` (list · list_page · get · get_payload · tail · replay) · `deliveries` (list · list_page · get) ·
|
|
164
|
+
`replay_destinations` (create · list · delete · enable · set_ordered · rotate_signing_secret ·
|
|
165
|
+
list_signing_secrets) · `subscriptions` (create · list · delete) · `audit.verify` · `whoami`.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
Apache-2.0
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# webhook.co Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python SDK for the [webhook.co](https://webhook.co) API. A typed client with the hardening
|
|
4
|
+
you'd otherwise hand-roll: bearer auth, bounded retries with jitter, cursor pagination, idempotency, and
|
|
5
|
+
secret redaction — with pydantic v2 models generated from the same OpenAPI contract the API is built on.
|
|
6
|
+
|
|
7
|
+
Python 3.10+. Built on `httpx`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install webhook-co
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import os
|
|
19
|
+
from webhook_co import WebhookClient
|
|
20
|
+
|
|
21
|
+
client = WebhookClient(api_key=os.environ["WEBHOOK_API_KEY"])
|
|
22
|
+
|
|
23
|
+
# Create an endpoint — the one-time ingest URL is returned once, so capture it now.
|
|
24
|
+
endpoint = client.endpoints.create(name="orders-prod")
|
|
25
|
+
print(endpoint.ingest_url)
|
|
26
|
+
|
|
27
|
+
# List events for that endpoint (auto-paginates).
|
|
28
|
+
for event in client.events.list(endpoint.id):
|
|
29
|
+
print(event.id, event.provider, event.verification_state)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The API key is a `whk_`-prefixed token from your dashboard. Keep it server-side — this SDK never prints
|
|
33
|
+
it, but it's still a credential.
|
|
34
|
+
|
|
35
|
+
Responses come back as validated pydantic models, so attributes are typed and snake_cased
|
|
36
|
+
(`endpoint.org_id`, `endpoint.created_at`).
|
|
37
|
+
|
|
38
|
+
## Pagination
|
|
39
|
+
|
|
40
|
+
List methods return a `Paginator` you can iterate directly — it follows the cursor for you:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
for endpoint in client.endpoints.list(name="prod"):
|
|
44
|
+
print(endpoint.id)
|
|
45
|
+
|
|
46
|
+
# Collect everything (careful with large result sets):
|
|
47
|
+
failed = client.deliveries.list(status=["failed"]).collect()
|
|
48
|
+
|
|
49
|
+
# One page at a time (e.g. to build your own UI):
|
|
50
|
+
page = client.endpoints.list_page(limit=50)
|
|
51
|
+
print(page.items, page.next_cursor)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Errors
|
|
55
|
+
|
|
56
|
+
Every failure is a `WebhookError` subclass, so you can narrow with `except` — no string matching:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from webhook_co import (
|
|
60
|
+
WebhookRateLimitError,
|
|
61
|
+
WebhookNotFoundError,
|
|
62
|
+
WebhookAuthenticationError,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
client.endpoints.get(endpoint_id)
|
|
67
|
+
except WebhookNotFoundError:
|
|
68
|
+
... # 404 — no such endpoint
|
|
69
|
+
except WebhookRateLimitError as err:
|
|
70
|
+
print(f"retry after {err.retry_after_s}s")
|
|
71
|
+
except WebhookAuthenticationError:
|
|
72
|
+
... # 401 — the key is invalid, expired, or revoked
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Each error carries `code` (a stable capability-error string), `status` (the HTTP status), and
|
|
76
|
+
`request_id` when the server sent one.
|
|
77
|
+
|
|
78
|
+
## Retries & idempotency
|
|
79
|
+
|
|
80
|
+
The client retries idempotent requests on transient failures (429/502/503/504 and network errors) with
|
|
81
|
+
capped exponential backoff and jitter, honouring `Retry-After`. It **never** blind-retries a
|
|
82
|
+
non-idempotent write — creating an endpoint, rotating a secret, or an un-keyed replay won't be sent twice
|
|
83
|
+
by the SDK. Replays carry an idempotency key (auto-generated if you don't pass one), so those are safe:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import uuid
|
|
87
|
+
|
|
88
|
+
client.events.replay(
|
|
89
|
+
event.id,
|
|
90
|
+
target={"kind": "destination", "destinationId": destination_id},
|
|
91
|
+
idempotency_key=str(uuid.uuid4()),
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Tune the budget per client:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
client = WebhookClient(api_key, max_retries=4, timeout_s=15) # defaults: 2 retries, 30s
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Payloads
|
|
102
|
+
|
|
103
|
+
`events.get_payload` decodes the wire envelope and hands you the exact bytes (length-checked, so a
|
|
104
|
+
truncated body raises rather than silently short-reading):
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
payload = client.events.get_payload(event.id)
|
|
108
|
+
print(payload.content_type, len(payload.body)) # payload.body is bytes
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Configuration
|
|
112
|
+
|
|
113
|
+
| Argument | Default | Notes |
|
|
114
|
+
| -------------- | ------------------------ | ------------------------------------------------------------ |
|
|
115
|
+
| `api_key` | — | Required. A `whk_` API key. |
|
|
116
|
+
| `base_url` | `https://api.webhook.co` | Must be https (loopback http allowed for self-host / dev). |
|
|
117
|
+
| `http_client` | a new `httpx.Client` | Pass your own for custom transports/proxies. |
|
|
118
|
+
| `max_retries` | `2` | Retries after the first attempt, idempotent requests only. |
|
|
119
|
+
| `timeout_s` | `30` | Per-request timeout (seconds), per httpx connect/read/write phase. |
|
|
120
|
+
| `refresh_auth` | — | Hook returning a rotated bearer on a 401 (OAuth flows). |
|
|
121
|
+
| `on_debug` | — | Redacted, single-line diagnostics — never the raw key. |
|
|
122
|
+
|
|
123
|
+
Use it as a context manager to close the underlying connection pool:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
with WebhookClient(api_key) as client:
|
|
127
|
+
client.whoami()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## API surface
|
|
131
|
+
|
|
132
|
+
`endpoints` (list · list_page · get · create · delete · rotate · `provider_secrets` add/list/revoke) ·
|
|
133
|
+
`events` (list · list_page · get · get_payload · tail · replay) · `deliveries` (list · list_page · get) ·
|
|
134
|
+
`replay_destinations` (create · list · delete · enable · set_ordered · rotate_signing_secret ·
|
|
135
|
+
list_signing_secrets) · `subscriptions` (create · list · delete) · `audit.verify` · `whoami`.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
Apache-2.0
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "webhook-co"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The official Python SDK for the webhook.co API — a typed client with retries, cursor pagination, idempotency, and secret redaction."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "webhook.co" }]
|
|
13
|
+
keywords = ["webhook", "webhooks", "sdk", "standard-webhooks", "webhook.co", "api-client"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["httpx>=0.27", "pydantic>=2.7"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://webhook.co"
|
|
29
|
+
Repository = "https://github.com/webhook-co/webhook"
|
|
30
|
+
Issues = "https://github.com/webhook-co/webhook/issues"
|
|
31
|
+
|
|
32
|
+
# Pinned exactly so the committed generated models (src/webhook_co/_generated/models.py) are byte-stable
|
|
33
|
+
# across environments and the drift guard (tests/test_generated_drift.py) is reproducible in CI.
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8",
|
|
37
|
+
"pytest-cov>=5",
|
|
38
|
+
"datamodel-code-generator==0.67.0",
|
|
39
|
+
"black==26.5.1",
|
|
40
|
+
"isort==8.0.1",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/webhook_co"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
# Make the top-level `scripts` package importable by the generated-types drift test.
|
|
49
|
+
pythonpath = ["."]
|
|
50
|
+
|
|
51
|
+
[tool.coverage.run]
|
|
52
|
+
source = ["webhook_co"]
|
|
53
|
+
# The generated models carry no hand-written logic; they are drift-guarded, not coverage-guarded.
|
|
54
|
+
omit = ["*/webhook_co/_generated/*"]
|
|
55
|
+
|
|
56
|
+
[tool.coverage.report]
|
|
57
|
+
exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"]
|
|
58
|
+
|
|
59
|
+
# The generated models are formatted by datamodel-code-generator's own black/isort pass; standalone
|
|
60
|
+
# runs must not touch them or they'd diverge from a fresh generation (the drift test compares bytes).
|
|
61
|
+
[tool.black]
|
|
62
|
+
target-version = ["py310"]
|
|
63
|
+
extend-exclude = "/_generated/"
|
|
64
|
+
|
|
65
|
+
[tool.isort]
|
|
66
|
+
profile = "black"
|
|
67
|
+
extend_skip_glob = ["*/_generated/*"]
|
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate the SDK's pydantic v2 models from the golden OpenAPI document.
|
|
3
|
+
|
|
4
|
+
The models are DERIVED from packages/openapi/src/openapi.json (itself drift-guarded against the Zod
|
|
5
|
+
contract), so the SDK can never silently diverge from the wire shape. The output is committed and
|
|
6
|
+
drift-tested (tests/test_generated_drift.py) exactly like the golden spec, using PINNED tool versions
|
|
7
|
+
(see pyproject.toml [project.optional-dependencies].dev) so the bytes are reproducible in CI.
|
|
8
|
+
|
|
9
|
+
Pipeline: run datamodel-code-generator, then collapse the scalar RootModel wrappers it emits for
|
|
10
|
+
`anyOf: [constrained-scalar, null]` fields (so `delivery.status_code == 200` works instead of silently
|
|
11
|
+
being False), then re-format with black. `render()` is the single source of truth used by both `main()`
|
|
12
|
+
and the drift test, so the committed bytes are exactly reproducible.
|
|
13
|
+
|
|
14
|
+
Regenerate: python -m scripts.generate_models (from sdks/python, with the dev extras installed)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import pathlib
|
|
20
|
+
import re
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
|
|
25
|
+
import black
|
|
26
|
+
|
|
27
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
28
|
+
PACKAGE_ROOT = HERE.parent
|
|
29
|
+
SPEC = PACKAGE_ROOT.parent.parent / "packages" / "openapi" / "src" / "openapi.json"
|
|
30
|
+
OUT = PACKAGE_ROOT / "src" / "webhook_co" / "_generated" / "models.py"
|
|
31
|
+
|
|
32
|
+
# Flags chosen for a modern, stable pydantic v2 surface: PEP 604 unions (`X | None`), builtin generics
|
|
33
|
+
# (`list`/`dict`), and `Annotated` field constraints, snake_case attributes with camelCase aliases,
|
|
34
|
+
# `format: uuid` as plain str, forward-compatible `extra=ignore`, and OPEN enums (so an older SDK doesn't
|
|
35
|
+
# crash on a new server-side enum value). Keep this list in lockstep with the drift test via `render()`.
|
|
36
|
+
ARGS = [
|
|
37
|
+
"--input-file-type",
|
|
38
|
+
"openapi",
|
|
39
|
+
"--output-model-type",
|
|
40
|
+
"pydantic_v2.BaseModel",
|
|
41
|
+
"--target-python-version",
|
|
42
|
+
"3.10",
|
|
43
|
+
"--use-annotated",
|
|
44
|
+
"--use-standard-collections",
|
|
45
|
+
"--use-union-operator",
|
|
46
|
+
"--use-schema-description",
|
|
47
|
+
"--snake-case-field",
|
|
48
|
+
"--type-mappings",
|
|
49
|
+
"uuid=string",
|
|
50
|
+
"--ignore-enum-constraints",
|
|
51
|
+
"--extra-fields",
|
|
52
|
+
"ignore",
|
|
53
|
+
"--formatters",
|
|
54
|
+
"black",
|
|
55
|
+
"isort",
|
|
56
|
+
"--disable-timestamp",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# The pinned black target — matches the SDK's minimum Python and keeps the collapse re-format reproducible.
|
|
60
|
+
_BLACK_MODE = black.Mode(target_versions={black.TargetVersion.PY310})
|
|
61
|
+
|
|
62
|
+
_SIMPLE_BASES = {"int", "str", "float", "bool"}
|
|
63
|
+
_ROOT_HEADER = re.compile(r"^class (\w+)\(RootModel\[(\w+)\]\):$")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_argv(input_path: pathlib.Path, output_path: pathlib.Path) -> list[str]:
|
|
67
|
+
return [
|
|
68
|
+
sys.executable,
|
|
69
|
+
"-m",
|
|
70
|
+
"datamodel_code_generator",
|
|
71
|
+
"--input",
|
|
72
|
+
str(input_path),
|
|
73
|
+
"--output",
|
|
74
|
+
str(output_path),
|
|
75
|
+
*ARGS,
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _collapse_scalar_root_models(text: str) -> str:
|
|
80
|
+
"""Inline `class X(RootModel[<scalar>])` wrappers to their base scalar.
|
|
81
|
+
|
|
82
|
+
datamodel-code-generator wraps a constrained scalar inside an `anyOf: [scalar, null]` in a named
|
|
83
|
+
RootModel (e.g. `StatusCode(RootModel[int])`), typing the field as `StatusCode | None`. That reads
|
|
84
|
+
wrong (`delivery.status_code == 200` is always False; `str(destination_id)` is `"root=..."`). Only
|
|
85
|
+
the SCALAR RootModels are collapsed; a union RootModel (e.g. `AuditVerifyResponse`) is left intact.
|
|
86
|
+
"""
|
|
87
|
+
lines = text.split("\n")
|
|
88
|
+
kept: list[str] = []
|
|
89
|
+
mapping: dict[str, str] = {}
|
|
90
|
+
i = 0
|
|
91
|
+
while i < len(lines):
|
|
92
|
+
header = _ROOT_HEADER.match(lines[i])
|
|
93
|
+
if header and header.group(2) in _SIMPLE_BASES:
|
|
94
|
+
mapping[header.group(1)] = header.group(2)
|
|
95
|
+
# Drop the class block: its header + all following blank/indented body lines.
|
|
96
|
+
i += 1
|
|
97
|
+
while i < len(lines) and (
|
|
98
|
+
lines[i] == "" or lines[i].startswith((" ", "\t"))
|
|
99
|
+
):
|
|
100
|
+
i += 1
|
|
101
|
+
continue
|
|
102
|
+
kept.append(lines[i])
|
|
103
|
+
i += 1
|
|
104
|
+
result = "\n".join(kept)
|
|
105
|
+
for name, base in mapping.items():
|
|
106
|
+
result = re.sub(rf"\b{name}\b", base, result)
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def render(spec_path: pathlib.Path) -> str:
|
|
111
|
+
"""The full generation pipeline (datamodel-codegen → collapse scalar RootModels → black). The single
|
|
112
|
+
source of truth for the committed file and the drift test.
|
|
113
|
+
"""
|
|
114
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
115
|
+
raw_out = pathlib.Path(tmp) / "models.py"
|
|
116
|
+
subprocess.run(build_argv(spec_path, raw_out), check=True, capture_output=True)
|
|
117
|
+
raw = raw_out.read_text()
|
|
118
|
+
collapsed = _collapse_scalar_root_models(raw)
|
|
119
|
+
return black.format_str(collapsed, mode=_BLACK_MODE)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main() -> None:
|
|
123
|
+
OUT.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
OUT.write_text(render(SPEC))
|
|
125
|
+
print(f"✓ generated {OUT}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The official Python SDK for the webhook.co API.
|
|
2
|
+
|
|
3
|
+
from webhook_co import WebhookClient
|
|
4
|
+
|
|
5
|
+
client = WebhookClient(api_key="whk_...")
|
|
6
|
+
for endpoint in client.endpoints.list():
|
|
7
|
+
print(endpoint.id, endpoint.name)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ._errors import (
|
|
13
|
+
WebhookAPIError,
|
|
14
|
+
WebhookAuthenticationError,
|
|
15
|
+
WebhookConfigError,
|
|
16
|
+
WebhookConflictError,
|
|
17
|
+
WebhookConnectionError,
|
|
18
|
+
WebhookError,
|
|
19
|
+
WebhookInvalidRequestError,
|
|
20
|
+
WebhookNotFoundError,
|
|
21
|
+
WebhookPermissionError,
|
|
22
|
+
WebhookRateLimitError,
|
|
23
|
+
WebhookTargetUnreachableError,
|
|
24
|
+
WebhookUnexpectedResponseError,
|
|
25
|
+
)
|
|
26
|
+
from ._generated.models import (
|
|
27
|
+
AddedProviderSecret,
|
|
28
|
+
AuditVerifyResponse,
|
|
29
|
+
AuthContext,
|
|
30
|
+
CreatedEndpoint,
|
|
31
|
+
CreatedReplayDestination,
|
|
32
|
+
DeletedEndpoint,
|
|
33
|
+
Delivery,
|
|
34
|
+
DeliveryAttempt,
|
|
35
|
+
Endpoint,
|
|
36
|
+
Event,
|
|
37
|
+
EventsTailResponse,
|
|
38
|
+
EventSummary,
|
|
39
|
+
ProviderSecretSummary,
|
|
40
|
+
ReplayDestination,
|
|
41
|
+
ReplayDestinationDeleted,
|
|
42
|
+
RevokedProviderSecret,
|
|
43
|
+
RotatedSigningSecret,
|
|
44
|
+
SigningSecretMetadata,
|
|
45
|
+
Subscription,
|
|
46
|
+
SubscriptionDeleted,
|
|
47
|
+
)
|
|
48
|
+
from ._pagination import Page, Paginator
|
|
49
|
+
from .client import EventPayload, WebhookClient
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"WebhookClient",
|
|
53
|
+
"EventPayload",
|
|
54
|
+
"Page",
|
|
55
|
+
"Paginator",
|
|
56
|
+
# errors
|
|
57
|
+
"WebhookError",
|
|
58
|
+
"WebhookConfigError",
|
|
59
|
+
"WebhookConnectionError",
|
|
60
|
+
"WebhookAPIError",
|
|
61
|
+
"WebhookAuthenticationError",
|
|
62
|
+
"WebhookPermissionError",
|
|
63
|
+
"WebhookNotFoundError",
|
|
64
|
+
"WebhookInvalidRequestError",
|
|
65
|
+
"WebhookConflictError",
|
|
66
|
+
"WebhookRateLimitError",
|
|
67
|
+
"WebhookTargetUnreachableError",
|
|
68
|
+
"WebhookUnexpectedResponseError",
|
|
69
|
+
# models
|
|
70
|
+
"Endpoint",
|
|
71
|
+
"CreatedEndpoint",
|
|
72
|
+
"DeletedEndpoint",
|
|
73
|
+
"Event",
|
|
74
|
+
"EventSummary",
|
|
75
|
+
"EventsTailResponse",
|
|
76
|
+
"Delivery",
|
|
77
|
+
"DeliveryAttempt",
|
|
78
|
+
"ReplayDestination",
|
|
79
|
+
"CreatedReplayDestination",
|
|
80
|
+
"ReplayDestinationDeleted",
|
|
81
|
+
"RotatedSigningSecret",
|
|
82
|
+
"SigningSecretMetadata",
|
|
83
|
+
"Subscription",
|
|
84
|
+
"SubscriptionDeleted",
|
|
85
|
+
"AddedProviderSecret",
|
|
86
|
+
"ProviderSecretSummary",
|
|
87
|
+
"RevokedProviderSecret",
|
|
88
|
+
"AuditVerifyResponse",
|
|
89
|
+
"AuthContext",
|
|
90
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Client configuration resolution.
|
|
2
|
+
|
|
3
|
+
The base URL is the destination for the bearer credential, so it MUST be https — plaintext http is
|
|
4
|
+
rejected except for loopback (local dev / self-host) — otherwise a misconfiguration could downgrade the
|
|
5
|
+
key to plaintext or redirect it to a hostile host. The URL must be a clean origin+path (no query/
|
|
6
|
+
fragment); the normalised form is returned.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from ._errors import WebhookConfigError
|
|
14
|
+
|
|
15
|
+
#: The canonical hosted API (mirrors the OpenAPI ``servers`` entry).
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.webhook.co"
|
|
17
|
+
|
|
18
|
+
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_base_url(raw: str | None) -> str:
|
|
22
|
+
"""Validate + normalise a base URL (defaulting to the hosted API).
|
|
23
|
+
|
|
24
|
+
https-only except loopback http; no query/fragment; a trailing slash is stripped so
|
|
25
|
+
``f"{base}/v1/…"`` concatenation is always well-formed.
|
|
26
|
+
"""
|
|
27
|
+
value = raw or DEFAULT_BASE_URL
|
|
28
|
+
parts = urlsplit(value)
|
|
29
|
+
if not parts.scheme or not parts.netloc:
|
|
30
|
+
raise WebhookConfigError(f"invalid base URL: {value}")
|
|
31
|
+
loopback_http_ok = parts.scheme == "http" and parts.hostname in _LOOPBACK_HOSTS
|
|
32
|
+
if parts.scheme != "https" and not loopback_http_ok:
|
|
33
|
+
raise WebhookConfigError(
|
|
34
|
+
f"base URL must be https (got {parts.scheme}://): {value}"
|
|
35
|
+
)
|
|
36
|
+
if parts.query or parts.fragment:
|
|
37
|
+
raise WebhookConfigError(
|
|
38
|
+
f"base URL must not carry a query or fragment: {value}"
|
|
39
|
+
)
|
|
40
|
+
return f"{parts.scheme}://{parts.netloc}{parts.path}".rstrip("/")
|