workwae 0.1.0a1__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.
- workwae-0.1.0a1/.gitignore +5 -0
- workwae-0.1.0a1/PKG-INFO +249 -0
- workwae-0.1.0a1/README.md +229 -0
- workwae-0.1.0a1/pyproject.toml +58 -0
- workwae-0.1.0a1/src/workwae/__init__.py +52 -0
- workwae-0.1.0a1/src/workwae/_client.py +154 -0
- workwae-0.1.0a1/src/workwae/_core.py +263 -0
- workwae-0.1.0a1/src/workwae/_errors.py +72 -0
- workwae-0.1.0a1/src/workwae/_models.py +17 -0
- workwae-0.1.0a1/src/workwae/_resources.py +8656 -0
- workwae-0.1.0a1/src/workwae/_version.py +1 -0
- workwae-0.1.0a1/src/workwae/pagination.py +159 -0
- workwae-0.1.0a1/src/workwae/py.typed +0 -0
- workwae-0.1.0a1/src/workwae/types.py +3846 -0
- workwae-0.1.0a1/src/workwae/webhooks.py +204 -0
workwae-0.1.0a1/PKG-INFO
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: workwae
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Typed client for the Workwae email, calendar and contacts API, with pagination, error and webhook signature helpers
|
|
5
|
+
Project-URL: Homepage, https://workwae.com
|
|
6
|
+
Project-URL: Documentation, https://docs.workwae.com/guides/python-sdk
|
|
7
|
+
Project-URL: Changelog, https://docs.workwae.com/changelog
|
|
8
|
+
Project-URL: Issues, https://workwae.com/support
|
|
9
|
+
License: Proprietary
|
|
10
|
+
Keywords: calendar,contacts,email,gmail,imap,microsoft-graph,sdk,webhooks,workwae
|
|
11
|
+
Classifier: License :: Other/Proprietary License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: httpx<1,>=0.27
|
|
17
|
+
Requires-Dist: pydantic<3,>=2.11
|
|
18
|
+
Requires-Dist: typing-extensions>=4.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# workwae
|
|
22
|
+
|
|
23
|
+
The Python client for the [Workwae](https://workwae.com) API: one REST API for Gmail,
|
|
24
|
+
Microsoft 365 and IMAP mailboxes, Google, Microsoft and CalDAV calendars, and contacts. Every
|
|
25
|
+
method, argument and answer is typed from the API's OpenAPI document.
|
|
26
|
+
|
|
27
|
+
- Sync and asyncio clients with a method for every `/v1` operation
|
|
28
|
+
- Answers as pydantic models, with date-times as `datetime`
|
|
29
|
+
- Cursor helpers for every listing and the change feed
|
|
30
|
+
- Errors keyed by stable reasons, with the request id
|
|
31
|
+
- Automatic retries of 429 and 503 answers, honoring `Retry-After`
|
|
32
|
+
- Idempotency keys on every POST
|
|
33
|
+
- Webhook signature verification (Standard Webhooks) and typed event parsing
|
|
34
|
+
|
|
35
|
+
The full guides and API reference are at https://docs.workwae.com (`/reference` for every
|
|
36
|
+
operation, `/changelog` for what changed).
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pip install workwae
|
|
42
|
+
# or
|
|
43
|
+
uv add workwae
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Python 3.10 or later. The runtime dependencies are `httpx` and `pydantic` (v2).
|
|
47
|
+
|
|
48
|
+
## Create a client
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import os
|
|
52
|
+
|
|
53
|
+
from workwae import Workwae
|
|
54
|
+
|
|
55
|
+
workwae = Workwae(api_key=os.environ["WORKWAE_API_KEY"])
|
|
56
|
+
|
|
57
|
+
page = workwae.accounts.list(limit=50)
|
|
58
|
+
for account in page.items:
|
|
59
|
+
print(account.email, account.status)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Option | Meaning |
|
|
63
|
+
| ----------------- | ---------------------------------------------------------------------------------------------- |
|
|
64
|
+
| `api_key` | An API key (`wk_...`), an account token (`wat_...`), or a function returning one per request. Defaults to `WORKWAE_API_KEY`. |
|
|
65
|
+
| `base_url` | Defaults to `https://api.workwae.com`. Set it only to route through your own proxy or gateway. |
|
|
66
|
+
| `headers` | Extra headers sent with every request. |
|
|
67
|
+
| `timeout` | Seconds, or an `httpx.Timeout`. Default 60. |
|
|
68
|
+
| `max_retries` | Further attempts after a 429 or 503. Default 2. See [Retries](#retries). |
|
|
69
|
+
| `max_retry_delay` | Longest single wait, in seconds. Default 60. |
|
|
70
|
+
| `http_client` | Replaces the `httpx.Client` the client makes (proxies, transports, tests). |
|
|
71
|
+
|
|
72
|
+
`AsyncWorkwae` takes the same options (`api_key` may be an async function, `http_client` an
|
|
73
|
+
`httpx.AsyncClient`) and its methods are coroutines. Both close their HTTP client with `close()`
|
|
74
|
+
or as a context manager.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from workwae import AsyncWorkwae
|
|
78
|
+
|
|
79
|
+
async with AsyncWorkwae() as workwae:
|
|
80
|
+
account = await workwae.accounts.get(account_id=account_id)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Methods are named after the operation, grouped by resource, in snake_case:
|
|
84
|
+
`workwae.accounts.get`, `workwae.outbox.create`, `workwae.calendar_events.list`,
|
|
85
|
+
`workwae.suppressions.entries.add`. Path, query and body fields are keyword arguments, also in
|
|
86
|
+
snake_case. Nested objects take the model or a plain dict with the same snake_case keys (each
|
|
87
|
+
model `X` has an `XParam` TypedDict for it). `from` is spelled `from_`.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
message = workwae.messages.get(account_id=account_id, message_id=message_id)
|
|
91
|
+
|
|
92
|
+
queued = workwae.outbox.create(
|
|
93
|
+
account_id=account_id,
|
|
94
|
+
to=[{"name": "Ada Lovelace", "address": "ada@example.com"}],
|
|
95
|
+
subject="Hello",
|
|
96
|
+
text="Sent with Workwae.",
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
An argument left out is not sent; `None` sends `null`, which clears a nullable field:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
workwae.accounts.update(account_id=account_id, external_ref=None)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`request_options` carries per-call options: `idempotency_key`, `timeout` and `headers`.
|
|
107
|
+
|
|
108
|
+
Answers are pydantic models (`workwae.types`), whose fields are snake_case. Operations without
|
|
109
|
+
a JSON answer return `None`, or a `BinaryResponse` (`content`, `content_type`, `filename`) for a
|
|
110
|
+
raw message, an attachment, a contact photo or an export.
|
|
111
|
+
|
|
112
|
+
## Authentication
|
|
113
|
+
|
|
114
|
+
API keys are created on the console under **API keys**, with the scopes the integration needs
|
|
115
|
+
(`read`, `write`, `send`, `admin`). They reach every account of the tenant, so keep them on your
|
|
116
|
+
server. To let a browser or mobile app read one account directly, mint a short-lived account
|
|
117
|
+
token on your server and hand it over; it is limited to that one account:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
token = workwae.accounts.mint_token(account_id=account_id, ttl_seconds=900)
|
|
121
|
+
print(token.token, token.expires_at)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Pagination
|
|
125
|
+
|
|
126
|
+
Listings answer `items` and `next_cursor`; pass `next_cursor` back as `cursor` until it is
|
|
127
|
+
`None`. The helpers take the method itself, so its arguments and item type stay checked, and
|
|
128
|
+
give an async iterator for an `AsyncWorkwae` method:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from workwae import changes, pages, paginate
|
|
132
|
+
|
|
133
|
+
# Every item, to the last page.
|
|
134
|
+
for message in paginate(workwae.messages.list, account_id=account_id, unread=True):
|
|
135
|
+
print(message.subject)
|
|
136
|
+
|
|
137
|
+
# Page by page.
|
|
138
|
+
for page in pages(workwae.accounts.list, limit=200):
|
|
139
|
+
print(len(page.items), page.next_cursor)
|
|
140
|
+
|
|
141
|
+
# The change feed, until caught up. Persist feed.cursor after processing and resume from it:
|
|
142
|
+
# a page is only passed once its last event was consumed (at-least-once delivery).
|
|
143
|
+
feed = changes(workwae.changes.list, cursor=load_cursor())
|
|
144
|
+
for event in feed:
|
|
145
|
+
handle(event)
|
|
146
|
+
save_cursor(feed.cursor)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Errors
|
|
150
|
+
|
|
151
|
+
An API refusal is raised as an `APIError` whose `code` is the error's stable reason
|
|
152
|
+
(`account_not_found`, `invalid_input`, `plan_limit_sends`, ...), `status` its HTTP status,
|
|
153
|
+
`message` a human-readable text, `data` the reason's details when it has any, `defined` whether
|
|
154
|
+
the operation declares the reason, and `request_id` the answer's `X-Request-Id` (quote it to
|
|
155
|
+
support).
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from workwae import APIError
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
workwae.outbox.create(account_id=account_id, to=[], text="hi")
|
|
162
|
+
except APIError as error:
|
|
163
|
+
if error.code == "invalid_input":
|
|
164
|
+
print(error.data["issues"])
|
|
165
|
+
else:
|
|
166
|
+
print(error.code, error.status, error.request_id)
|
|
167
|
+
raise
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`workwae.types.ErrorReason` is the union of every reason. An answer whose body is not an API
|
|
171
|
+
error has `code` `None`. Network failures are raised by httpx as they are (`httpx.HTTPError`); an
|
|
172
|
+
answer that does not match its model raises `pydantic.ValidationError`.
|
|
173
|
+
|
|
174
|
+
## Retries
|
|
175
|
+
|
|
176
|
+
429 (`rate_limited`, `too_many_requests`) and 503 (`overloaded`) answers are refused before the
|
|
177
|
+
API acts, so the client retries them: twice by default, waiting for `Retry-After`, or backing off
|
|
178
|
+
exponentially with jitter when there is none. A `Retry-After` longer than `max_retry_delay` is
|
|
179
|
+
not waited for: the error is raised.
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
workwae = Workwae(max_retries=4, max_retry_delay=30)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Idempotency keys
|
|
186
|
+
|
|
187
|
+
Every POST is sent with an `Idempotency-Key`, generated per call and reused across the client's
|
|
188
|
+
own retries, so a retried send never goes out twice. To make a call safe to repeat across
|
|
189
|
+
process restarts (a job queue retrying a send, say), pass a key of your own derived from your
|
|
190
|
+
data:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
workwae.outbox.create(
|
|
194
|
+
account_id=account_id,
|
|
195
|
+
to=[{"address": "ada@example.com"}],
|
|
196
|
+
subject="Invoice 1042",
|
|
197
|
+
text="...",
|
|
198
|
+
request_options={"idempotency_key": "invoice-1042"},
|
|
199
|
+
)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The API keeps the first answer for 24 hours per tenant and key and replays it for a repeat with
|
|
203
|
+
the same body. The same key with a different body is refused with `idempotency_key_reused`; a
|
|
204
|
+
repeat while the first call still runs with `idempotency_key_in_progress`.
|
|
205
|
+
|
|
206
|
+
## Webhooks
|
|
207
|
+
|
|
208
|
+
Workwae signs deliveries with [Standard Webhooks](https://www.standardwebhooks.com/).
|
|
209
|
+
`parse_webhook` verifies a delivery and returns its event, typed by `type`, with date-times as
|
|
210
|
+
`datetime`. Pass the raw body exactly as received:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from workwae import UnknownEvent, WebhookVerificationError, parse_webhook
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.post("/webhooks/workwae")
|
|
217
|
+
async def receive(request: Request) -> Response:
|
|
218
|
+
body = await request.body()
|
|
219
|
+
try:
|
|
220
|
+
event = parse_webhook(os.environ["WORKWAE_WEBHOOK_SECRET"], request.headers, body)
|
|
221
|
+
except WebhookVerificationError:
|
|
222
|
+
return Response(status_code=400)
|
|
223
|
+
if isinstance(event, UnknownEvent):
|
|
224
|
+
# An event type added after this SDK version.
|
|
225
|
+
return Response(status_code=204)
|
|
226
|
+
if event.type == "send.failed":
|
|
227
|
+
print(event.data.outbox_id, event.data.error.code)
|
|
228
|
+
return Response(status_code=204)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`verify_webhook(secret, headers, body)` only checks the signature and answers a boolean;
|
|
232
|
+
`check_webhook_signature` answers why it failed (`missing_headers`, `timestamp`, `signature`).
|
|
233
|
+
Both take `tolerance_seconds` (default `WEBHOOK_TOLERANCE_SECONDS`, 300). `headers` is any
|
|
234
|
+
mapping in any key case: a dict, or the request headers of Django, Flask, Starlette or httpx.
|
|
235
|
+
|
|
236
|
+
## Types
|
|
237
|
+
|
|
238
|
+
Every model of the API is in `workwae.types`, under the name the OpenAPI document gives it:
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
from workwae.types import Account, CalendarEvent, Event, EventType, Message, OutboxEntry
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`EVENT_TYPES` lists every event type at runtime (for webhook endpoint filters).
|
|
245
|
+
|
|
246
|
+
## Versioning
|
|
247
|
+
|
|
248
|
+
The SDK follows semver from 0.1.0; while it is 0.x, a minor version may carry breaking changes,
|
|
249
|
+
always listed in the [changelog](https://docs.workwae.com/changelog).
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# workwae
|
|
2
|
+
|
|
3
|
+
The Python client for the [Workwae](https://workwae.com) API: one REST API for Gmail,
|
|
4
|
+
Microsoft 365 and IMAP mailboxes, Google, Microsoft and CalDAV calendars, and contacts. Every
|
|
5
|
+
method, argument and answer is typed from the API's OpenAPI document.
|
|
6
|
+
|
|
7
|
+
- Sync and asyncio clients with a method for every `/v1` operation
|
|
8
|
+
- Answers as pydantic models, with date-times as `datetime`
|
|
9
|
+
- Cursor helpers for every listing and the change feed
|
|
10
|
+
- Errors keyed by stable reasons, with the request id
|
|
11
|
+
- Automatic retries of 429 and 503 answers, honoring `Retry-After`
|
|
12
|
+
- Idempotency keys on every POST
|
|
13
|
+
- Webhook signature verification (Standard Webhooks) and typed event parsing
|
|
14
|
+
|
|
15
|
+
The full guides and API reference are at https://docs.workwae.com (`/reference` for every
|
|
16
|
+
operation, `/changelog` for what changed).
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pip install workwae
|
|
22
|
+
# or
|
|
23
|
+
uv add workwae
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Python 3.10 or later. The runtime dependencies are `httpx` and `pydantic` (v2).
|
|
27
|
+
|
|
28
|
+
## Create a client
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import os
|
|
32
|
+
|
|
33
|
+
from workwae import Workwae
|
|
34
|
+
|
|
35
|
+
workwae = Workwae(api_key=os.environ["WORKWAE_API_KEY"])
|
|
36
|
+
|
|
37
|
+
page = workwae.accounts.list(limit=50)
|
|
38
|
+
for account in page.items:
|
|
39
|
+
print(account.email, account.status)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| Option | Meaning |
|
|
43
|
+
| ----------------- | ---------------------------------------------------------------------------------------------- |
|
|
44
|
+
| `api_key` | An API key (`wk_...`), an account token (`wat_...`), or a function returning one per request. Defaults to `WORKWAE_API_KEY`. |
|
|
45
|
+
| `base_url` | Defaults to `https://api.workwae.com`. Set it only to route through your own proxy or gateway. |
|
|
46
|
+
| `headers` | Extra headers sent with every request. |
|
|
47
|
+
| `timeout` | Seconds, or an `httpx.Timeout`. Default 60. |
|
|
48
|
+
| `max_retries` | Further attempts after a 429 or 503. Default 2. See [Retries](#retries). |
|
|
49
|
+
| `max_retry_delay` | Longest single wait, in seconds. Default 60. |
|
|
50
|
+
| `http_client` | Replaces the `httpx.Client` the client makes (proxies, transports, tests). |
|
|
51
|
+
|
|
52
|
+
`AsyncWorkwae` takes the same options (`api_key` may be an async function, `http_client` an
|
|
53
|
+
`httpx.AsyncClient`) and its methods are coroutines. Both close their HTTP client with `close()`
|
|
54
|
+
or as a context manager.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from workwae import AsyncWorkwae
|
|
58
|
+
|
|
59
|
+
async with AsyncWorkwae() as workwae:
|
|
60
|
+
account = await workwae.accounts.get(account_id=account_id)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Methods are named after the operation, grouped by resource, in snake_case:
|
|
64
|
+
`workwae.accounts.get`, `workwae.outbox.create`, `workwae.calendar_events.list`,
|
|
65
|
+
`workwae.suppressions.entries.add`. Path, query and body fields are keyword arguments, also in
|
|
66
|
+
snake_case. Nested objects take the model or a plain dict with the same snake_case keys (each
|
|
67
|
+
model `X` has an `XParam` TypedDict for it). `from` is spelled `from_`.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
message = workwae.messages.get(account_id=account_id, message_id=message_id)
|
|
71
|
+
|
|
72
|
+
queued = workwae.outbox.create(
|
|
73
|
+
account_id=account_id,
|
|
74
|
+
to=[{"name": "Ada Lovelace", "address": "ada@example.com"}],
|
|
75
|
+
subject="Hello",
|
|
76
|
+
text="Sent with Workwae.",
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
An argument left out is not sent; `None` sends `null`, which clears a nullable field:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
workwae.accounts.update(account_id=account_id, external_ref=None)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`request_options` carries per-call options: `idempotency_key`, `timeout` and `headers`.
|
|
87
|
+
|
|
88
|
+
Answers are pydantic models (`workwae.types`), whose fields are snake_case. Operations without
|
|
89
|
+
a JSON answer return `None`, or a `BinaryResponse` (`content`, `content_type`, `filename`) for a
|
|
90
|
+
raw message, an attachment, a contact photo or an export.
|
|
91
|
+
|
|
92
|
+
## Authentication
|
|
93
|
+
|
|
94
|
+
API keys are created on the console under **API keys**, with the scopes the integration needs
|
|
95
|
+
(`read`, `write`, `send`, `admin`). They reach every account of the tenant, so keep them on your
|
|
96
|
+
server. To let a browser or mobile app read one account directly, mint a short-lived account
|
|
97
|
+
token on your server and hand it over; it is limited to that one account:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
token = workwae.accounts.mint_token(account_id=account_id, ttl_seconds=900)
|
|
101
|
+
print(token.token, token.expires_at)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Pagination
|
|
105
|
+
|
|
106
|
+
Listings answer `items` and `next_cursor`; pass `next_cursor` back as `cursor` until it is
|
|
107
|
+
`None`. The helpers take the method itself, so its arguments and item type stay checked, and
|
|
108
|
+
give an async iterator for an `AsyncWorkwae` method:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from workwae import changes, pages, paginate
|
|
112
|
+
|
|
113
|
+
# Every item, to the last page.
|
|
114
|
+
for message in paginate(workwae.messages.list, account_id=account_id, unread=True):
|
|
115
|
+
print(message.subject)
|
|
116
|
+
|
|
117
|
+
# Page by page.
|
|
118
|
+
for page in pages(workwae.accounts.list, limit=200):
|
|
119
|
+
print(len(page.items), page.next_cursor)
|
|
120
|
+
|
|
121
|
+
# The change feed, until caught up. Persist feed.cursor after processing and resume from it:
|
|
122
|
+
# a page is only passed once its last event was consumed (at-least-once delivery).
|
|
123
|
+
feed = changes(workwae.changes.list, cursor=load_cursor())
|
|
124
|
+
for event in feed:
|
|
125
|
+
handle(event)
|
|
126
|
+
save_cursor(feed.cursor)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Errors
|
|
130
|
+
|
|
131
|
+
An API refusal is raised as an `APIError` whose `code` is the error's stable reason
|
|
132
|
+
(`account_not_found`, `invalid_input`, `plan_limit_sends`, ...), `status` its HTTP status,
|
|
133
|
+
`message` a human-readable text, `data` the reason's details when it has any, `defined` whether
|
|
134
|
+
the operation declares the reason, and `request_id` the answer's `X-Request-Id` (quote it to
|
|
135
|
+
support).
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from workwae import APIError
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
workwae.outbox.create(account_id=account_id, to=[], text="hi")
|
|
142
|
+
except APIError as error:
|
|
143
|
+
if error.code == "invalid_input":
|
|
144
|
+
print(error.data["issues"])
|
|
145
|
+
else:
|
|
146
|
+
print(error.code, error.status, error.request_id)
|
|
147
|
+
raise
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`workwae.types.ErrorReason` is the union of every reason. An answer whose body is not an API
|
|
151
|
+
error has `code` `None`. Network failures are raised by httpx as they are (`httpx.HTTPError`); an
|
|
152
|
+
answer that does not match its model raises `pydantic.ValidationError`.
|
|
153
|
+
|
|
154
|
+
## Retries
|
|
155
|
+
|
|
156
|
+
429 (`rate_limited`, `too_many_requests`) and 503 (`overloaded`) answers are refused before the
|
|
157
|
+
API acts, so the client retries them: twice by default, waiting for `Retry-After`, or backing off
|
|
158
|
+
exponentially with jitter when there is none. A `Retry-After` longer than `max_retry_delay` is
|
|
159
|
+
not waited for: the error is raised.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
workwae = Workwae(max_retries=4, max_retry_delay=30)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Idempotency keys
|
|
166
|
+
|
|
167
|
+
Every POST is sent with an `Idempotency-Key`, generated per call and reused across the client's
|
|
168
|
+
own retries, so a retried send never goes out twice. To make a call safe to repeat across
|
|
169
|
+
process restarts (a job queue retrying a send, say), pass a key of your own derived from your
|
|
170
|
+
data:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
workwae.outbox.create(
|
|
174
|
+
account_id=account_id,
|
|
175
|
+
to=[{"address": "ada@example.com"}],
|
|
176
|
+
subject="Invoice 1042",
|
|
177
|
+
text="...",
|
|
178
|
+
request_options={"idempotency_key": "invoice-1042"},
|
|
179
|
+
)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The API keeps the first answer for 24 hours per tenant and key and replays it for a repeat with
|
|
183
|
+
the same body. The same key with a different body is refused with `idempotency_key_reused`; a
|
|
184
|
+
repeat while the first call still runs with `idempotency_key_in_progress`.
|
|
185
|
+
|
|
186
|
+
## Webhooks
|
|
187
|
+
|
|
188
|
+
Workwae signs deliveries with [Standard Webhooks](https://www.standardwebhooks.com/).
|
|
189
|
+
`parse_webhook` verifies a delivery and returns its event, typed by `type`, with date-times as
|
|
190
|
+
`datetime`. Pass the raw body exactly as received:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from workwae import UnknownEvent, WebhookVerificationError, parse_webhook
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.post("/webhooks/workwae")
|
|
197
|
+
async def receive(request: Request) -> Response:
|
|
198
|
+
body = await request.body()
|
|
199
|
+
try:
|
|
200
|
+
event = parse_webhook(os.environ["WORKWAE_WEBHOOK_SECRET"], request.headers, body)
|
|
201
|
+
except WebhookVerificationError:
|
|
202
|
+
return Response(status_code=400)
|
|
203
|
+
if isinstance(event, UnknownEvent):
|
|
204
|
+
# An event type added after this SDK version.
|
|
205
|
+
return Response(status_code=204)
|
|
206
|
+
if event.type == "send.failed":
|
|
207
|
+
print(event.data.outbox_id, event.data.error.code)
|
|
208
|
+
return Response(status_code=204)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`verify_webhook(secret, headers, body)` only checks the signature and answers a boolean;
|
|
212
|
+
`check_webhook_signature` answers why it failed (`missing_headers`, `timestamp`, `signature`).
|
|
213
|
+
Both take `tolerance_seconds` (default `WEBHOOK_TOLERANCE_SECONDS`, 300). `headers` is any
|
|
214
|
+
mapping in any key case: a dict, or the request headers of Django, Flask, Starlette or httpx.
|
|
215
|
+
|
|
216
|
+
## Types
|
|
217
|
+
|
|
218
|
+
Every model of the API is in `workwae.types`, under the name the OpenAPI document gives it:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
from workwae.types import Account, CalendarEvent, Event, EventType, Message, OutboxEntry
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`EVENT_TYPES` lists every event type at runtime (for webhook endpoint filters).
|
|
225
|
+
|
|
226
|
+
## Versioning
|
|
227
|
+
|
|
228
|
+
The SDK follows semver from 0.1.0; while it is 0.x, a minor version may carry breaking changes,
|
|
229
|
+
always listed in the [changelog](https://docs.workwae.com/changelog).
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "workwae"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Typed client for the Workwae email, calendar and contacts API, with pagination, error and webhook signature helpers"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "Proprietary" }
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
keywords = ["workwae", "email", "calendar", "contacts", "gmail", "microsoft-graph", "imap", "webhooks", "sdk"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Programming Language :: Python :: 3",
|
|
11
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
12
|
+
"Typing :: Typed",
|
|
13
|
+
"License :: Other/Proprietary License",
|
|
14
|
+
]
|
|
15
|
+
dependencies = ["httpx>=0.27,<1", "pydantic>=2.11,<3", "typing-extensions>=4.12"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://workwae.com"
|
|
19
|
+
Documentation = "https://docs.workwae.com/guides/python-sdk"
|
|
20
|
+
Changelog = "https://docs.workwae.com/changelog"
|
|
21
|
+
Issues = "https://workwae.com/support"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = ["pytest>=9.2", "ruff>=0.17", "pyright>=1.1.414"]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["hatchling>=1.27"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.version]
|
|
31
|
+
path = "src/workwae/_version.py"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/workwae"]
|
|
35
|
+
|
|
36
|
+
# Only the package and its README. Hatch also adds the nearest .gitignore, so this directory has
|
|
37
|
+
# its own.
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
only-include = ["src/workwae", "README.md"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 100
|
|
43
|
+
target-version = "py310"
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff.lint.per-file-ignores]
|
|
49
|
+
# Generated: docstrings carry the OpenAPI document's descriptions as they are.
|
|
50
|
+
"src/workwae/{types,_resources}.py" = ["E501"]
|
|
51
|
+
|
|
52
|
+
[tool.pyright]
|
|
53
|
+
include = ["src", "tests"]
|
|
54
|
+
pythonVersion = "3.10"
|
|
55
|
+
typeCheckingMode = "strict"
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""workwae: a typed client for the Workwae /v1 API, plus cursor, error and webhook helpers.
|
|
2
|
+
|
|
3
|
+
Every API model is in `workwae.types`, under the name the OpenAPI document gives it."""
|
|
4
|
+
|
|
5
|
+
from . import types
|
|
6
|
+
from ._client import AsyncWorkwae, Workwae
|
|
7
|
+
from ._core import NOT_GIVEN, BinaryResponse, NotGiven, RequestOptions
|
|
8
|
+
from ._errors import APIError, WorkwaeError
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .pagination import AsyncChangeFeed, ChangeFeed, changes, pages, paginate
|
|
11
|
+
from .types import EVENT_TYPES, ErrorReason, Event
|
|
12
|
+
from .webhooks import (
|
|
13
|
+
MAX_SIGNATURE_CANDIDATES,
|
|
14
|
+
WEBHOOK_TOLERANCE_SECONDS,
|
|
15
|
+
UnknownEvent,
|
|
16
|
+
WebhookEvent,
|
|
17
|
+
WebhookVerificationError,
|
|
18
|
+
WebhookVerificationFailure,
|
|
19
|
+
check_webhook_signature,
|
|
20
|
+
parse_webhook,
|
|
21
|
+
verify_webhook,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"EVENT_TYPES",
|
|
26
|
+
"MAX_SIGNATURE_CANDIDATES",
|
|
27
|
+
"NOT_GIVEN",
|
|
28
|
+
"WEBHOOK_TOLERANCE_SECONDS",
|
|
29
|
+
"APIError",
|
|
30
|
+
"AsyncChangeFeed",
|
|
31
|
+
"AsyncWorkwae",
|
|
32
|
+
"BinaryResponse",
|
|
33
|
+
"ChangeFeed",
|
|
34
|
+
"ErrorReason",
|
|
35
|
+
"Event",
|
|
36
|
+
"NotGiven",
|
|
37
|
+
"RequestOptions",
|
|
38
|
+
"UnknownEvent",
|
|
39
|
+
"WebhookEvent",
|
|
40
|
+
"WebhookVerificationError",
|
|
41
|
+
"WebhookVerificationFailure",
|
|
42
|
+
"Workwae",
|
|
43
|
+
"WorkwaeError",
|
|
44
|
+
"__version__",
|
|
45
|
+
"changes",
|
|
46
|
+
"check_webhook_signature",
|
|
47
|
+
"pages",
|
|
48
|
+
"paginate",
|
|
49
|
+
"parse_webhook",
|
|
50
|
+
"types",
|
|
51
|
+
"verify_webhook",
|
|
52
|
+
]
|