voxcore-sdk 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.
- voxcore_sdk-0.1.0/.gitignore +40 -0
- voxcore_sdk-0.1.0/PKG-INFO +173 -0
- voxcore_sdk-0.1.0/README.md +143 -0
- voxcore_sdk-0.1.0/pyproject.toml +55 -0
- voxcore_sdk-0.1.0/src/voxcore/__about__.py +1 -0
- voxcore_sdk-0.1.0/src/voxcore/__init__.py +41 -0
- voxcore_sdk-0.1.0/src/voxcore/_http.py +242 -0
- voxcore_sdk-0.1.0/src/voxcore/client.py +130 -0
- voxcore_sdk-0.1.0/src/voxcore/errors.py +158 -0
- voxcore_sdk-0.1.0/src/voxcore/py.typed +0 -0
- voxcore_sdk-0.1.0/src/voxcore/resources/__init__.py +277 -0
- voxcore_sdk-0.1.0/src/voxcore/resources/_base.py +134 -0
- voxcore_sdk-0.1.0/tests/test_client.py +554 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Secrets. .env holds SECRET_KEY and the field-encryption key-ring; losing it
|
|
2
|
+
# is bad, committing it is worse. .env.example is the template that IS tracked.
|
|
3
|
+
.env
|
|
4
|
+
.env.local
|
|
5
|
+
.env*.local
|
|
6
|
+
|
|
7
|
+
# The deployment env dumps. Same contents as .env and then some: PROD-ENV.txt
|
|
8
|
+
# carries FIELD_ENCRYPTION_KEYS next to DB_PASSWORD, which together decrypt
|
|
9
|
+
# every encrypted column in production, plus the Stripe, Telnyx, Sendblue and
|
|
10
|
+
# model-provider keys. Matched by name anywhere in the tree rather than by path,
|
|
11
|
+
# because the next copy of it will be somewhere else.
|
|
12
|
+
PROD-ENV.txt
|
|
13
|
+
prod-env.txt
|
|
14
|
+
*prod-env*.txt
|
|
15
|
+
*PROD-ENV*.txt
|
|
16
|
+
|
|
17
|
+
# Python
|
|
18
|
+
backend/venv/
|
|
19
|
+
__pycache__/
|
|
20
|
+
*.py[cod]
|
|
21
|
+
backend/db.sqlite3
|
|
22
|
+
# The test runner's database, left behind when a run is interrupted.
|
|
23
|
+
backend/test_db.sqlite3
|
|
24
|
+
*.sqlite3
|
|
25
|
+
backend/staticfiles/
|
|
26
|
+
backend/media/
|
|
27
|
+
|
|
28
|
+
# Node
|
|
29
|
+
node_modules/
|
|
30
|
+
frontend/.next/
|
|
31
|
+
frontend/out/
|
|
32
|
+
frontend/.turbo/
|
|
33
|
+
*.tsbuildinfo
|
|
34
|
+
next-env.d.ts
|
|
35
|
+
|
|
36
|
+
# Editors / OS
|
|
37
|
+
.DS_Store
|
|
38
|
+
Thumbs.db
|
|
39
|
+
.idea/
|
|
40
|
+
.vscode/
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: voxcore-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The official Python client for VOXCORE — AI agents that answer your phone, text your customers, and talk to your website visitors.
|
|
5
|
+
Project-URL: Homepage, https://voxcore.net
|
|
6
|
+
Project-URL: Documentation, https://voxcore.net/docs
|
|
7
|
+
Project-URL: Source, https://github.com/CodeCraftStudios/voxcore-python
|
|
8
|
+
Author-email: CodeCraft Studios <support@voxcore.net>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agents,ai,sms,telephony,voice,voxcore
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Communications :: Telephony
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: httpx<1,>=0.24
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.5; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
27
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# VOXCORE for Python
|
|
32
|
+
|
|
33
|
+
The official Python client for [VOXCORE](https://voxcore.net) — AI agents that answer your phone, text your customers, and talk to visitors on your website.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install voxcore-sdk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The distribution is `voxcore-sdk`; what you import is `voxcore`.
|
|
40
|
+
|
|
41
|
+
## Getting started
|
|
42
|
+
|
|
43
|
+
You need a **secret key** and your **organization id**. Both come from your dashboard: keys under Settings → API keys, and the org id (`org__…`) from the URL.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from voxcore import Voxcore
|
|
47
|
+
|
|
48
|
+
vox = Voxcore(api_key="vox_sk_live_…", org="org__…")
|
|
49
|
+
|
|
50
|
+
for contact in vox.contacts.iterate():
|
|
51
|
+
print(contact["display_name"])
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or leave them out and set `VOXCORE_API_KEY` and `VOXCORE_ORG` in the environment, which is where a key belongs — not in a file that ends up in a commit:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
vox = Voxcore()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The client holds a connection pool, so reuse one rather than making a new one per call. It works as a context manager if you want the pool closed:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
with Voxcore() as vox:
|
|
64
|
+
vox.agents.list()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Listing things
|
|
68
|
+
|
|
69
|
+
Every collection has the same five verbs — `list`, `iterate`, `retrieve`, `create`, `update`, `delete` — and two ways to read:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
vox.contacts.list(status="customer") # the first page, as a list
|
|
73
|
+
vox.contacts.iterate(status="customer") # every page, as a lazy iterator
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`list()` returns **one page**. That is deliberate: a tenant with forty thousand contacts should not discover that `list()` quietly made sixteen hundred requests. Use `iterate()` when you want all of them — it follows the API's cursor, so rows arriving while you read do not shift the pages under you.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
consented = [
|
|
80
|
+
c for c in vox.contacts.iterate()
|
|
81
|
+
if c["marketing_consent_message"]
|
|
82
|
+
]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Writing things
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
contact = vox.contacts.create(
|
|
89
|
+
first_name="Dana",
|
|
90
|
+
phone="+18175550147", # E.164, always
|
|
91
|
+
status="lead",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
vox.contacts.update(contact["id"], status="customer")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Retries and idempotency
|
|
98
|
+
|
|
99
|
+
`GET`s are retried automatically on connection failures, 429s and 5xx, with backoff.
|
|
100
|
+
|
|
101
|
+
**Writes are not**, unless you make them safe. Repeating a `POST` can create a second contact or place a second call, so the client will not do it on its own. Pass an idempotency key and it will:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
vox.contacts.create(first_name="Dana", idempotency_key=True) # generated
|
|
105
|
+
vox.contacts.create(first_name="Dana", idempotency_key="order-8127") # yours
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Use your own key when you have a natural one — an order id, a row id — because that is what makes the retry safe across process restarts too.
|
|
109
|
+
|
|
110
|
+
Sending a text is keyed by default. Texting somebody twice is worse than the alternative:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
vox.threads.send("thr__…", "Your order is ready.")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Errors
|
|
117
|
+
|
|
118
|
+
Everything inherits from `VoxcoreError`, so one `except` catches everything this library raises and nothing it does not.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from voxcore import Voxcore, AuthenticationError, PermissionDenied, RateLimitError
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
vox.contacts.list()
|
|
125
|
+
except AuthenticationError:
|
|
126
|
+
... # the key is wrong, expired or revoked — retrying will never help
|
|
127
|
+
except PermissionDenied:
|
|
128
|
+
... # the key is fine and lacks a scope — fix it in the dashboard
|
|
129
|
+
except RateLimitError as e:
|
|
130
|
+
... # retries were already exhausted; e.retry_after is seconds
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Every error carries `.code` (VOXCORE's machine-readable string — branch on this, it is more stable than the HTTP status), `.message`, `.status` and `.request_id`. Quote the request id in a support conversation; it is how a specific request is found in the logs.
|
|
134
|
+
|
|
135
|
+
## What you can reach
|
|
136
|
+
|
|
137
|
+
| | |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `vox.agents` | Agents — what they say, how they sound. Also `.publish()` |
|
|
140
|
+
| `vox.calls` | Calls in and out, and `.transcript()` |
|
|
141
|
+
| `vox.campaigns` | Outbound calling campaigns. `.start()`, `.pause()`, `.attempts()` |
|
|
142
|
+
| `vox.contacts` | The people you talk to. `.find_by_phone()` |
|
|
143
|
+
| `vox.contact_batches` | Named audiences. `.members()`, `.add()`, `.exclude()` |
|
|
144
|
+
| `vox.threads` | Texting conversations. `.send()` |
|
|
145
|
+
| `vox.phone_numbers` | Your lines |
|
|
146
|
+
| `vox.widgets`, `vox.forms`, `vox.flows` | Website widgets, forms, messaging sequences |
|
|
147
|
+
| `vox.meetings`, `vox.meeting_types` | Booking |
|
|
148
|
+
| `vox.keys` | API keys |
|
|
149
|
+
|
|
150
|
+
Anything not wrapped yet is still reachable, and that is on purpose — an SDK that lags the API by a release is an SDK that blocks you:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
vox.request("GET", "/api/v1/orgs/org__…/something-new")
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Two things worth knowing
|
|
157
|
+
|
|
158
|
+
**Consent is read-only.** `marketing_consent_call`, `marketing_consent_message` and `marketing_consent_email` can be read but never written through the API. They record what a customer actually said — with a timestamp, the call it came from, and a verbatim quote — and a field a script can set is not evidence that anybody said anything. The opt-out flags (`do_not_call`, `do_not_message`) *are* writable, because someone ringing to ask to be removed has to be recordable by whoever takes the call.
|
|
159
|
+
|
|
160
|
+
**Publishing is not saving.** Updating an agent does not change what callers hear. A live conversation pins the agent's published version, so changes reach the phone only when you publish:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
vox.agents.update(agent_id, system_prompt="…")
|
|
164
|
+
vox.agents.publish(agent_id, notes="new opening line")
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Requirements
|
|
168
|
+
|
|
169
|
+
Python 3.9+. One dependency: `httpx`.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# VOXCORE for Python
|
|
2
|
+
|
|
3
|
+
The official Python client for [VOXCORE](https://voxcore.net) — AI agents that answer your phone, text your customers, and talk to visitors on your website.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install voxcore-sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The distribution is `voxcore-sdk`; what you import is `voxcore`.
|
|
10
|
+
|
|
11
|
+
## Getting started
|
|
12
|
+
|
|
13
|
+
You need a **secret key** and your **organization id**. Both come from your dashboard: keys under Settings → API keys, and the org id (`org__…`) from the URL.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from voxcore import Voxcore
|
|
17
|
+
|
|
18
|
+
vox = Voxcore(api_key="vox_sk_live_…", org="org__…")
|
|
19
|
+
|
|
20
|
+
for contact in vox.contacts.iterate():
|
|
21
|
+
print(contact["display_name"])
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or leave them out and set `VOXCORE_API_KEY` and `VOXCORE_ORG` in the environment, which is where a key belongs — not in a file that ends up in a commit:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
vox = Voxcore()
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The client holds a connection pool, so reuse one rather than making a new one per call. It works as a context manager if you want the pool closed:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
with Voxcore() as vox:
|
|
34
|
+
vox.agents.list()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Listing things
|
|
38
|
+
|
|
39
|
+
Every collection has the same five verbs — `list`, `iterate`, `retrieve`, `create`, `update`, `delete` — and two ways to read:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
vox.contacts.list(status="customer") # the first page, as a list
|
|
43
|
+
vox.contacts.iterate(status="customer") # every page, as a lazy iterator
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`list()` returns **one page**. That is deliberate: a tenant with forty thousand contacts should not discover that `list()` quietly made sixteen hundred requests. Use `iterate()` when you want all of them — it follows the API's cursor, so rows arriving while you read do not shift the pages under you.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
consented = [
|
|
50
|
+
c for c in vox.contacts.iterate()
|
|
51
|
+
if c["marketing_consent_message"]
|
|
52
|
+
]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Writing things
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
contact = vox.contacts.create(
|
|
59
|
+
first_name="Dana",
|
|
60
|
+
phone="+18175550147", # E.164, always
|
|
61
|
+
status="lead",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
vox.contacts.update(contact["id"], status="customer")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Retries and idempotency
|
|
68
|
+
|
|
69
|
+
`GET`s are retried automatically on connection failures, 429s and 5xx, with backoff.
|
|
70
|
+
|
|
71
|
+
**Writes are not**, unless you make them safe. Repeating a `POST` can create a second contact or place a second call, so the client will not do it on its own. Pass an idempotency key and it will:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
vox.contacts.create(first_name="Dana", idempotency_key=True) # generated
|
|
75
|
+
vox.contacts.create(first_name="Dana", idempotency_key="order-8127") # yours
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Use your own key when you have a natural one — an order id, a row id — because that is what makes the retry safe across process restarts too.
|
|
79
|
+
|
|
80
|
+
Sending a text is keyed by default. Texting somebody twice is worse than the alternative:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
vox.threads.send("thr__…", "Your order is ready.")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Errors
|
|
87
|
+
|
|
88
|
+
Everything inherits from `VoxcoreError`, so one `except` catches everything this library raises and nothing it does not.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from voxcore import Voxcore, AuthenticationError, PermissionDenied, RateLimitError
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
vox.contacts.list()
|
|
95
|
+
except AuthenticationError:
|
|
96
|
+
... # the key is wrong, expired or revoked — retrying will never help
|
|
97
|
+
except PermissionDenied:
|
|
98
|
+
... # the key is fine and lacks a scope — fix it in the dashboard
|
|
99
|
+
except RateLimitError as e:
|
|
100
|
+
... # retries were already exhausted; e.retry_after is seconds
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Every error carries `.code` (VOXCORE's machine-readable string — branch on this, it is more stable than the HTTP status), `.message`, `.status` and `.request_id`. Quote the request id in a support conversation; it is how a specific request is found in the logs.
|
|
104
|
+
|
|
105
|
+
## What you can reach
|
|
106
|
+
|
|
107
|
+
| | |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `vox.agents` | Agents — what they say, how they sound. Also `.publish()` |
|
|
110
|
+
| `vox.calls` | Calls in and out, and `.transcript()` |
|
|
111
|
+
| `vox.campaigns` | Outbound calling campaigns. `.start()`, `.pause()`, `.attempts()` |
|
|
112
|
+
| `vox.contacts` | The people you talk to. `.find_by_phone()` |
|
|
113
|
+
| `vox.contact_batches` | Named audiences. `.members()`, `.add()`, `.exclude()` |
|
|
114
|
+
| `vox.threads` | Texting conversations. `.send()` |
|
|
115
|
+
| `vox.phone_numbers` | Your lines |
|
|
116
|
+
| `vox.widgets`, `vox.forms`, `vox.flows` | Website widgets, forms, messaging sequences |
|
|
117
|
+
| `vox.meetings`, `vox.meeting_types` | Booking |
|
|
118
|
+
| `vox.keys` | API keys |
|
|
119
|
+
|
|
120
|
+
Anything not wrapped yet is still reachable, and that is on purpose — an SDK that lags the API by a release is an SDK that blocks you:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
vox.request("GET", "/api/v1/orgs/org__…/something-new")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Two things worth knowing
|
|
127
|
+
|
|
128
|
+
**Consent is read-only.** `marketing_consent_call`, `marketing_consent_message` and `marketing_consent_email` can be read but never written through the API. They record what a customer actually said — with a timestamp, the call it came from, and a verbatim quote — and a field a script can set is not evidence that anybody said anything. The opt-out flags (`do_not_call`, `do_not_message`) *are* writable, because someone ringing to ask to be removed has to be recordable by whoever takes the call.
|
|
129
|
+
|
|
130
|
+
**Publishing is not saving.** Updating an agent does not change what callers hear. A live conversation pins the agent's published version, so changes reach the phone only when you publish:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
vox.agents.update(agent_id, system_prompt="…")
|
|
134
|
+
vox.agents.publish(agent_id, notes="new opening line")
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Requirements
|
|
138
|
+
|
|
139
|
+
Python 3.9+. One dependency: `httpx`.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# The distribution is "voxcore-sdk"; the import stays "voxcore".
|
|
7
|
+
#
|
|
8
|
+
# PyPI refused "voxcore": it normalises to the same name as "vox-core", which
|
|
9
|
+
# is already registered to somebody else, so it is not available at any point
|
|
10
|
+
# in the future either. The import name is ours regardless, which is the half
|
|
11
|
+
# that appears in every line of code somebody writes. Same arrangement as
|
|
12
|
+
# python-dateutil -> dateutil.
|
|
13
|
+
name = "voxcore-sdk"
|
|
14
|
+
dynamic = ["version"]
|
|
15
|
+
description = "The official Python client for VOXCORE — AI agents that answer your phone, text your customers, and talk to your website visitors."
|
|
16
|
+
readme = "README.md"
|
|
17
|
+
requires-python = ">=3.9"
|
|
18
|
+
license = "MIT"
|
|
19
|
+
authors = [{ name = "CodeCraft Studios", email = "support@voxcore.net" }]
|
|
20
|
+
keywords = ["voxcore", "voice", "ai", "telephony", "sms", "agents"]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 4 - Beta",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.9",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Topic :: Communications :: Telephony",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
]
|
|
34
|
+
dependencies = ["httpx>=0.24,<1"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://voxcore.net"
|
|
38
|
+
Documentation = "https://voxcore.net/docs"
|
|
39
|
+
Source = "https://github.com/CodeCraftStudios/voxcore-python"
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
dev = ["pytest>=7", "respx>=0.20", "mypy>=1.5", "ruff>=0.5"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.version]
|
|
45
|
+
path = "src/voxcore/__about__.py"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/voxcore"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 88
|
|
52
|
+
target-version = "py39"
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VOXCORE for Python.
|
|
3
|
+
|
|
4
|
+
from voxcore import Voxcore
|
|
5
|
+
|
|
6
|
+
vox = Voxcore()
|
|
7
|
+
for contact in vox.contacts.iterate(status="customer"):
|
|
8
|
+
print(contact["display_name"])
|
|
9
|
+
|
|
10
|
+
Everything is reached through ``Voxcore``. Errors all inherit from
|
|
11
|
+
``VoxcoreError``, so one except clause catches everything this library raises
|
|
12
|
+
and nothing it does not.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .__about__ import __version__
|
|
16
|
+
from .client import Voxcore
|
|
17
|
+
from .errors import (
|
|
18
|
+
APIError,
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
ConnectionError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
PermissionDenied,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
ServerError,
|
|
25
|
+
ValidationError,
|
|
26
|
+
VoxcoreError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Voxcore",
|
|
31
|
+
"VoxcoreError",
|
|
32
|
+
"APIError",
|
|
33
|
+
"AuthenticationError",
|
|
34
|
+
"PermissionDenied",
|
|
35
|
+
"NotFoundError",
|
|
36
|
+
"ValidationError",
|
|
37
|
+
"RateLimitError",
|
|
38
|
+
"ServerError",
|
|
39
|
+
"ConnectionError",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The one place a request is made, and the only place retries are decided.
|
|
3
|
+
|
|
4
|
+
Everything in ``resources/`` is a thin call into ``Transport.request``. That is
|
|
5
|
+
deliberate: retry policy, error mapping, idempotency and the auth header are
|
|
6
|
+
each the kind of rule that is correct once and then wrong at whichever call
|
|
7
|
+
site forgot it, so no resource is allowed its own.
|
|
8
|
+
|
|
9
|
+
**What is retried, and what is not.** A GET is safe to repeat, so connection
|
|
10
|
+
failures, 429s and 5xx are retried with backoff. A POST is not -- repeating one
|
|
11
|
+
can create a second contact or place a second call -- so it is retried only
|
|
12
|
+
when the caller supplied an idempotency key, which is what makes the repeat
|
|
13
|
+
safe on the server. Retrying an unkeyed POST is how one request becomes two
|
|
14
|
+
phone calls to the same person.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import random
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from typing import Any, Iterator, Mapping
|
|
23
|
+
from urllib.parse import urlparse
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from . import errors
|
|
28
|
+
from .__about__ import __version__
|
|
29
|
+
|
|
30
|
+
DEFAULT_BASE_URL = "https://api.voxcore.net"
|
|
31
|
+
DEFAULT_TIMEOUT = 30.0
|
|
32
|
+
DEFAULT_MAX_RETRIES = 2
|
|
33
|
+
|
|
34
|
+
# Repeated with backoff. 408 and 409 are absent on purpose: a timeout the
|
|
35
|
+
# server reported may have completed, and a conflict is a state problem that a
|
|
36
|
+
# second identical request cannot fix.
|
|
37
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
38
|
+
|
|
39
|
+
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Transport:
|
|
43
|
+
"""HTTP, auth, retries and error mapping. Not part of the public API."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
api_key: str,
|
|
49
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
50
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
51
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
52
|
+
http_client: httpx.Client | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
if not api_key:
|
|
55
|
+
raise errors.VoxcoreError(
|
|
56
|
+
"An API key is required. Pass api_key=..., or set VOXCORE_API_KEY."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
self._api_key = api_key
|
|
60
|
+
self._base_url = base_url.rstrip("/")
|
|
61
|
+
self._max_retries = max(0, int(max_retries))
|
|
62
|
+
self._owns_client = http_client is None
|
|
63
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
64
|
+
|
|
65
|
+
# ---- lifecycle -------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def close(self) -> None:
|
|
68
|
+
# Only what we opened. A caller who passed their own pooled client
|
|
69
|
+
# expects it to still work after this object goes away.
|
|
70
|
+
if self._owns_client:
|
|
71
|
+
self._client.close()
|
|
72
|
+
|
|
73
|
+
# ---- the request -----------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def request(
|
|
76
|
+
self,
|
|
77
|
+
method: str,
|
|
78
|
+
path: str,
|
|
79
|
+
*,
|
|
80
|
+
params: Mapping[str, Any] | None = None,
|
|
81
|
+
json: Any = None,
|
|
82
|
+
idempotency_key: str | None = None,
|
|
83
|
+
) -> Any:
|
|
84
|
+
url = path if path.startswith("http") else f"{self._base_url}{path}"
|
|
85
|
+
method = method.upper()
|
|
86
|
+
|
|
87
|
+
headers = {
|
|
88
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
89
|
+
"Accept": "application/json",
|
|
90
|
+
"User-Agent": f"voxcore-python/{__version__}",
|
|
91
|
+
}
|
|
92
|
+
if idempotency_key:
|
|
93
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
94
|
+
|
|
95
|
+
# A keyed write is safe to repeat; an unkeyed one is not. See the
|
|
96
|
+
# module docstring -- this single line is the whole policy.
|
|
97
|
+
retryable = method in SAFE_METHODS or bool(idempotency_key)
|
|
98
|
+
attempts = self._max_retries + 1 if retryable else 1
|
|
99
|
+
|
|
100
|
+
last_error: Exception | None = None
|
|
101
|
+
|
|
102
|
+
for attempt in range(attempts):
|
|
103
|
+
if attempt:
|
|
104
|
+
time.sleep(self._backoff(attempt, last_error))
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
response = self._client.request(
|
|
108
|
+
method,
|
|
109
|
+
url,
|
|
110
|
+
params=self._clean(params),
|
|
111
|
+
json=json,
|
|
112
|
+
headers=headers,
|
|
113
|
+
)
|
|
114
|
+
except httpx.TimeoutException as failure:
|
|
115
|
+
last_error = errors.ConnectionError(
|
|
116
|
+
f"The request to {url} timed out."
|
|
117
|
+
)
|
|
118
|
+
last_error.__cause__ = failure
|
|
119
|
+
continue
|
|
120
|
+
except httpx.HTTPError as failure:
|
|
121
|
+
last_error = errors.ConnectionError(f"Could not reach {url}: {failure}")
|
|
122
|
+
last_error.__cause__ = failure
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
if response.status_code in RETRY_STATUSES and attempt < attempts - 1:
|
|
126
|
+
last_error = self._to_error(response)
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if response.status_code >= 400:
|
|
130
|
+
raise self._to_error(response)
|
|
131
|
+
|
|
132
|
+
if response.status_code == 204 or not response.content:
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
return response.json()
|
|
136
|
+
except ValueError as failure:
|
|
137
|
+
raise errors.APIError(
|
|
138
|
+
"The API returned a response that was not JSON.",
|
|
139
|
+
status=response.status_code,
|
|
140
|
+
request_id=response.headers.get("X-Request-Id", ""),
|
|
141
|
+
body=response.text[:500],
|
|
142
|
+
) from failure
|
|
143
|
+
|
|
144
|
+
raise last_error or errors.ConnectionError(f"Could not reach {url}.")
|
|
145
|
+
|
|
146
|
+
# ---- pagination ------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def paginate(
|
|
149
|
+
self, path: str, *, params: Mapping[str, Any] | None = None
|
|
150
|
+
) -> Iterator[dict]:
|
|
151
|
+
"""
|
|
152
|
+
Every item across every page, one at a time.
|
|
153
|
+
|
|
154
|
+
Follows ``page.next``, which is a full URL the API builds, rather than
|
|
155
|
+
incrementing an offset. VOXCORE paginates on a cursor precisely because
|
|
156
|
+
an offset shifts when a row is added mid-read, and reconstructing the
|
|
157
|
+
next request here instead of following the given link would put that
|
|
158
|
+
bug back.
|
|
159
|
+
"""
|
|
160
|
+
query: Mapping[str, Any] | None = params
|
|
161
|
+
url = path
|
|
162
|
+
|
|
163
|
+
while url:
|
|
164
|
+
payload = self.request("GET", url, params=query)
|
|
165
|
+
if not isinstance(payload, dict):
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
for item in payload.get("data") or []:
|
|
169
|
+
yield item
|
|
170
|
+
|
|
171
|
+
page = payload.get("page") or {}
|
|
172
|
+
url = page.get("next") or ""
|
|
173
|
+
# The next link already carries the cursor and the limit; sending
|
|
174
|
+
# the original params again would override the cursor with the
|
|
175
|
+
# first page's and loop forever.
|
|
176
|
+
query = None
|
|
177
|
+
|
|
178
|
+
# ---- helpers ---------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _to_error(self, response: httpx.Response) -> errors.APIError:
|
|
181
|
+
try:
|
|
182
|
+
body = response.json()
|
|
183
|
+
except ValueError:
|
|
184
|
+
body = response.text[:500]
|
|
185
|
+
|
|
186
|
+
failure = errors.from_response(
|
|
187
|
+
response.status_code, body, response.headers.get("X-Request-Id", "")
|
|
188
|
+
)
|
|
189
|
+
if isinstance(failure, errors.RateLimitError):
|
|
190
|
+
header = response.headers.get("Retry-After")
|
|
191
|
+
if header:
|
|
192
|
+
try:
|
|
193
|
+
failure.retry_after = float(header)
|
|
194
|
+
except ValueError:
|
|
195
|
+
pass
|
|
196
|
+
return failure
|
|
197
|
+
|
|
198
|
+
def _backoff(self, attempt: int, last_error: Exception | None) -> float:
|
|
199
|
+
"""
|
|
200
|
+
Exponential, with jitter, and the server's own number when it gave one.
|
|
201
|
+
|
|
202
|
+
The jitter is not decoration. Clients that back off on identical
|
|
203
|
+
schedules retry in lockstep and arrive together, which is how a service
|
|
204
|
+
that was recovering is knocked over by its own users.
|
|
205
|
+
"""
|
|
206
|
+
if isinstance(last_error, errors.RateLimitError) and last_error.retry_after:
|
|
207
|
+
return min(float(last_error.retry_after), 60.0)
|
|
208
|
+
return min(0.5 * (2 ** (attempt - 1)), 8.0) * (1 + random.random() * 0.25)
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _clean(params: Mapping[str, Any] | None) -> dict | None:
|
|
212
|
+
"""
|
|
213
|
+
Drop None, and render booleans the way the API reads them.
|
|
214
|
+
|
|
215
|
+
``None`` means "the caller did not pass this", and forwarding it as the
|
|
216
|
+
string "None" turns an omitted filter into a filter for the literal
|
|
217
|
+
word. Python's ``True`` would go over as "True", which Django's query
|
|
218
|
+
parsing does not read as true.
|
|
219
|
+
"""
|
|
220
|
+
if not params:
|
|
221
|
+
return None
|
|
222
|
+
out = {}
|
|
223
|
+
for key, value in params.items():
|
|
224
|
+
if value is None:
|
|
225
|
+
continue
|
|
226
|
+
if isinstance(value, bool):
|
|
227
|
+
out[key] = "true" if value else "false"
|
|
228
|
+
elif isinstance(value, (list, tuple)):
|
|
229
|
+
out[key] = ",".join(str(v) for v in value)
|
|
230
|
+
else:
|
|
231
|
+
out[key] = value
|
|
232
|
+
return out or None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def new_idempotency_key() -> str:
|
|
236
|
+
"""A key for a write the caller wants to be safe to retry."""
|
|
237
|
+
return f"vox-{uuid.uuid4()}"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def looks_like_production(base_url: str) -> bool:
|
|
241
|
+
host = (urlparse(base_url).hostname or "").lower()
|
|
242
|
+
return host.endswith("voxcore.net")
|