hirefinn 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.
- hirefinn-0.1.0/.gitignore +8 -0
- hirefinn-0.1.0/LICENSE +21 -0
- hirefinn-0.1.0/PKG-INFO +224 -0
- hirefinn-0.1.0/README.md +204 -0
- hirefinn-0.1.0/finn/__init__.py +66 -0
- hirefinn-0.1.0/finn/_approval.py +136 -0
- hirefinn-0.1.0/finn/_client.py +156 -0
- hirefinn-0.1.0/finn/_compat.py +4 -0
- hirefinn-0.1.0/finn/_core.py +385 -0
- hirefinn-0.1.0/finn/_errors.py +273 -0
- hirefinn-0.1.0/finn/_generated/__init__.py +4 -0
- hirefinn-0.1.0/finn/_generated/request.py +323 -0
- hirefinn-0.1.0/finn/_generated/resources.py +2157 -0
- hirefinn-0.1.0/finn/_generated/schemas.py +1475 -0
- hirefinn-0.1.0/finn/_pagination.py +183 -0
- hirefinn-0.1.0/finn/_resource.py +52 -0
- hirefinn-0.1.0/finn/_types.py +53 -0
- hirefinn-0.1.0/finn/_version.py +1 -0
- hirefinn-0.1.0/finn/py.typed +0 -0
- hirefinn-0.1.0/finn/types.py +3 -0
- hirefinn-0.1.0/pyproject.toml +38 -0
- hirefinn-0.1.0/scripts/generate.py +782 -0
- hirefinn-0.1.0/scripts/pull-spec.sh +11 -0
- hirefinn-0.1.0/spec/openapi.json +18655 -0
- hirefinn-0.1.0/tests/conftest.py +4 -0
- hirefinn-0.1.0/tests/helpers.py +73 -0
- hirefinn-0.1.0/tests/test_async.py +100 -0
- hirefinn-0.1.0/tests/test_client.py +456 -0
- hirefinn-0.1.0/tests/test_extras.py +117 -0
- hirefinn-0.1.0/tests/test_types.py +18 -0
- hirefinn-0.1.0/tests/typecheck_sample.py +40 -0
hirefinn-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Finn (hirefinn.ai)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
hirefinn-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hirefinn
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Finn API
|
|
5
|
+
Project-URL: Homepage, https://hirefinn.ai
|
|
6
|
+
Project-URL: Documentation, https://api.hirefinn.ai/docs
|
|
7
|
+
Author: Finn
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Requires-Dist: httpx<1,>=0.23
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: mypy>=1.5; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# Finn Python SDK
|
|
22
|
+
|
|
23
|
+
Python client for the [Finn API](https://api.hirefinn.ai/docs) (`https://api.hirefinn.ai/v1`): calls, Finns,
|
|
24
|
+
voices, phone numbers, audiences, campaigns, wallet, do-not-call, CRM records, Operator cases, approvals, agents and
|
|
25
|
+
chat.
|
|
26
|
+
|
|
27
|
+
- Sync (`Finn`) and async (`AsyncFinn`) clients on [httpx](https://www.python-httpx.org/), the only dependency.
|
|
28
|
+
- Typed: every request body, query parameter and response in the spec is a `TypedDict` (`finn.types`), generated
|
|
29
|
+
from the API's OpenAPI spec.
|
|
30
|
+
- Retries with backoff on network errors, 408, 429 and 5xx, honouring `Retry-After`.
|
|
31
|
+
- Automatic `Idempotency-Key` on spending and outward writes, kept the same across retries.
|
|
32
|
+
- One iterator over every list style (cursor, offset, page, before).
|
|
33
|
+
- Typed exceptions for the API's error shapes; held writes come back as `PendingApproval`.
|
|
34
|
+
|
|
35
|
+
Python 3.9 or newer.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pip install hirefinn # not published yet; until then:
|
|
41
|
+
pip install git+https://github.com/Hire-FinnAI/finn-sdk-py.git
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The distribution is called `hirefinn` (the name `finn` on PyPI belongs to another project); you import `finn`.
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from finn import Finn, is_pending_approval
|
|
50
|
+
|
|
51
|
+
finn = Finn() # reads FINN_API_KEY; or Finn(api_key="finn_live_...")
|
|
52
|
+
|
|
53
|
+
# Place a call
|
|
54
|
+
call = finn.calls.create({"finn_id": "8f14e45f-...", "to_number": "4155550142", "country_code": "1"})
|
|
55
|
+
if is_pending_approval(call):
|
|
56
|
+
print("Waiting for a person to approve:", call.approve_url)
|
|
57
|
+
outcome = finn.wait_for_approval(call.approval_id)
|
|
58
|
+
else:
|
|
59
|
+
print(call["data"]["call_uuid"])
|
|
60
|
+
|
|
61
|
+
# Read one
|
|
62
|
+
c = finn.calls.retrieve(call_uuid)
|
|
63
|
+
print(c["data"]["status"])
|
|
64
|
+
|
|
65
|
+
# Every completed call, page by page under the hood
|
|
66
|
+
for summary in finn.calls.list_all(status="completed", limit=100):
|
|
67
|
+
print(summary["call_uuid"])
|
|
68
|
+
|
|
69
|
+
# CRM
|
|
70
|
+
rec = finn.contacts.create({"kind": "contact", "name": "Ada Lovelace", "phone": "+14155550142"})
|
|
71
|
+
finn.contacts.add_note(rec["data"]["id"], {"text": "Asked for a callback on Monday."})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Async:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import asyncio
|
|
78
|
+
from finn import AsyncFinn
|
|
79
|
+
|
|
80
|
+
async def main() -> None:
|
|
81
|
+
async with AsyncFinn() as finn:
|
|
82
|
+
agents = await finn.agents.list(limit=20)
|
|
83
|
+
async for conv in finn.chat.conversations.list_all():
|
|
84
|
+
print(conv["id"])
|
|
85
|
+
|
|
86
|
+
asyncio.run(main())
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Use the client as a context manager (`with Finn() as finn:`) or call `finn.close()` to release connections.
|
|
90
|
+
|
|
91
|
+
## Client options
|
|
92
|
+
|
|
93
|
+
| Option | Default | |
|
|
94
|
+
| --- | --- | --- |
|
|
95
|
+
| `api_key` | env `FINN_API_KEY` | `finn_live_...` org key, `finn_test_...` test key, or OAuth `finn_oat_...` token |
|
|
96
|
+
| `base_url` | `https://api.hirefinn.ai/v1` | |
|
|
97
|
+
| `org` | none | `Finn-Org` header, for tokens that can use more than one org |
|
|
98
|
+
| `version` | none | Pins the API version (`Finn-Version` header), e.g. `"2026-11-01"` |
|
|
99
|
+
| `max_retries` | `2` | Retries after the first attempt |
|
|
100
|
+
| `timeout` | `60` | Seconds per attempt |
|
|
101
|
+
| `auto_idempotency` | `True` | Generate `Idempotency-Key` on spending/outward writes |
|
|
102
|
+
| `max_retry_after` | `60` | Longest `Retry-After` (seconds) to wait out; longer raises |
|
|
103
|
+
| `default_headers` | none | Sent on every request |
|
|
104
|
+
| `http_client` | new `httpx.Client` | Bring your own (proxies, custom transport); the SDK does not close it |
|
|
105
|
+
|
|
106
|
+
Every method also takes the per-request keyword options `idempotency_key`, `org`, `version`, `max_retries`,
|
|
107
|
+
`timeout` and `extra_headers`, which override the client's setting for that call only. `extra_headers` can never
|
|
108
|
+
replace `Authorization`.
|
|
109
|
+
|
|
110
|
+
## Resources
|
|
111
|
+
|
|
112
|
+
| Attribute | Methods |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| `calls` | `create`, `list`, `list_all`, `retrieve` |
|
|
115
|
+
| `finns` | `list`, `list_all`, `create`, `retrieve`, `update`, `delete`, `list_knowledge_base`, `add_knowledge_base_document`, `post_call_analysis` |
|
|
116
|
+
| `voices` | `list`, `list_all`, `retrieve` |
|
|
117
|
+
| `phone_numbers` | `list`, `list_all`, `search_available`, `rent`, `retrieve`, `release` |
|
|
118
|
+
| `audiences` | `list`, `list_all`, `create`, `retrieve`, `archive`, `list_contacts`, `list_all_contacts`, `add_contacts`, `remove_contact` |
|
|
119
|
+
| `deployments` | `list`, `list_all`, `create_outbound`, `create_inbound`, `retrieve`, `stop`, `stop_inbound`, `analytics` |
|
|
120
|
+
| `wallet` | `retrieve`, `list_transactions`, `list_all_transactions` |
|
|
121
|
+
| `do_not_call` | `list`, `list_all`, `add`, `remove` |
|
|
122
|
+
| `contacts` | `list`, `list_all`, `create`, `retrieve`, `update`, `timeline`, `timeline_all`, `add_note`, `list_kinds`, `list_fields` |
|
|
123
|
+
| `cases` | `list`, `list_all`, `retrieve`, `list_events`, `start` |
|
|
124
|
+
| `approvals` | `list`, `list_all`, `retrieve`, `decide`, `approve`, `reject`, `wait` |
|
|
125
|
+
| `agents` | `list`, `list_all`, `create`, `draft`, `retrieve`, `save_draft`, `validate`, `test`, `publish`, `drive_test` |
|
|
126
|
+
| `chat` | `list_channels`; `chat.conversations`: `list`, `list_all`, `start`, `retrieve`, `list_messages`, `list_all_messages`, `send`; `chat.contacts`: `list`, `list_all`, `retrieve`, `update` |
|
|
127
|
+
|
|
128
|
+
Path parameters are positional, the request body is the next positional argument (a dict), and query parameters
|
|
129
|
+
are keyword arguments (`from` is spelled `from_`). Knowledge-base uploads take `files=`:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
with open("policy.pdf", "rb") as f:
|
|
133
|
+
finn.finns.add_knowledge_base_document(finn_id, {"name": "Refund policy"}, files={"file": f})
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Anything else, including endpoints newer than this SDK, goes through `request` (typed by method and path) or
|
|
137
|
+
`request_with_response` (untyped, returns status, headers and request id):
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
call = finn.request("get", "/calls/{call_uuid}", path_params={"call_uuid": call_uuid})
|
|
141
|
+
res = finn.request_with_response("get", "/wallet")
|
|
142
|
+
print(res.status, res.request_id, res.idempotent_replayed, res.data)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Retries
|
|
146
|
+
|
|
147
|
+
Failed attempts are retried up to `max_retries` times on network errors, 408, 429, 5xx and
|
|
148
|
+
`409 idempotency_in_progress`, waiting `Retry-After` when the API sends it and otherwise backing off exponentially
|
|
149
|
+
(0.5 s, 1 s, 2 s ... up to 8 s, with jitter). A `Retry-After` longer than `max_retry_after` raises at once.
|
|
150
|
+
Only requests that are safe to repeat are retried: `GET`, `PUT`, `DELETE`, and writes that carry an
|
|
151
|
+
`Idempotency-Key`.
|
|
152
|
+
|
|
153
|
+
## Idempotency keys
|
|
154
|
+
|
|
155
|
+
The SDK adds a random `Idempotency-Key` to `calls.create`, `phone_numbers.rent`, `deployments.create_outbound`,
|
|
156
|
+
`deployments.create_inbound`, `chat.conversations.start`, `chat.conversations.send`, `agents.create` and
|
|
157
|
+
`cases.start`, and keeps it across retries. Pass your own to make a retry from your side safe too, or `False` to
|
|
158
|
+
send none:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
finn.calls.create(body, idempotency_key=f"order-{order_id}")
|
|
162
|
+
finn.calls.create(body, idempotency_key=False)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Errors
|
|
166
|
+
|
|
167
|
+
Every API error raises `finn.FinnError` (or a subclass by status: `BadRequestError`, `AuthenticationError`,
|
|
168
|
+
`PaymentRequiredError`, `PermissionDeniedError`, `NotFoundError`, `ConflictError`, `PayloadTooLargeError`,
|
|
169
|
+
`RateLimitError`, `InternalServerError`). Branch on `code`, never on the message.
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from finn import FinnError, NotFoundError
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
finn.finns.retrieve(finn_id)
|
|
176
|
+
except NotFoundError:
|
|
177
|
+
...
|
|
178
|
+
except FinnError as e:
|
|
179
|
+
print(e.status, e.code, e.message, e.request_id)
|
|
180
|
+
print(e.fields) # per-field problems on invalid_request
|
|
181
|
+
print(e.required_scope) # on insufficient_scope
|
|
182
|
+
print(e.action_url) # dashboard page that fixes it (insufficient_credits, ...)
|
|
183
|
+
print(e.retry_after, e.doc_url, e.details)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
No HTTP answer at all raises `FinnConnectionError` (`FinnTimeoutError` when an attempt timed out).
|
|
187
|
+
`FinnSdkError` is the base of everything the SDK raises.
|
|
188
|
+
|
|
189
|
+
## Approvals
|
|
190
|
+
|
|
191
|
+
Outward and spending writes from OAuth and MCP callers may be held for a person to approve. They answer HTTP 202,
|
|
192
|
+
which the SDK returns as a `PendingApproval` (`approval_id`, `approve_url`, `expires_at`, `action`, `summary`).
|
|
193
|
+
`finn.wait_for_approval(id)` polls until the approval is decided and returns it; for a held request, `status` ends
|
|
194
|
+
as `succeeded`, `failed`, `declined` or `expired`, and `result` holds the held request's own answer.
|
|
195
|
+
|
|
196
|
+
## Pagination
|
|
197
|
+
|
|
198
|
+
`list_all(...)` methods return a paginator: iterate it for items, `.pages()` for raw pages, `.to_list(limit)` to
|
|
199
|
+
collect. Async paginators work with `async for` and `await .to_list()`.
|
|
200
|
+
|
|
201
|
+
## Regenerating from the spec
|
|
202
|
+
|
|
203
|
+
The spec is vendored in `spec/openapi.json`. To refresh it from a local checkout of the backend (prod branch):
|
|
204
|
+
|
|
205
|
+
```sh
|
|
206
|
+
BACKEND=../hirefinn-web-app-backend ./scripts/pull-spec.sh
|
|
207
|
+
# or from a file or URL:
|
|
208
|
+
python scripts/generate.py path/or/url/to/openapi.json
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`scripts/generate.py` (standard library only) writes `finn/_generated/`: TypedDicts for every schema, the
|
|
212
|
+
resource classes (grouped by the `RESOURCES` table in the script) and typed overloads for `request()`. It fails if
|
|
213
|
+
the table names an operation the spec lacks, and lists spec operations the table does not cover.
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
```sh
|
|
218
|
+
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
219
|
+
.venv/bin/pytest
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The tests use `httpx.MockTransport` and never touch the network; one of them type-checks a sample with mypy.
|
|
223
|
+
|
|
224
|
+
Releases go to PyPI from a GitHub release via Trusted Publishing; see [RELEASING.md](RELEASING.md).
|
hirefinn-0.1.0/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Finn Python SDK
|
|
2
|
+
|
|
3
|
+
Python client for the [Finn API](https://api.hirefinn.ai/docs) (`https://api.hirefinn.ai/v1`): calls, Finns,
|
|
4
|
+
voices, phone numbers, audiences, campaigns, wallet, do-not-call, CRM records, Operator cases, approvals, agents and
|
|
5
|
+
chat.
|
|
6
|
+
|
|
7
|
+
- Sync (`Finn`) and async (`AsyncFinn`) clients on [httpx](https://www.python-httpx.org/), the only dependency.
|
|
8
|
+
- Typed: every request body, query parameter and response in the spec is a `TypedDict` (`finn.types`), generated
|
|
9
|
+
from the API's OpenAPI spec.
|
|
10
|
+
- Retries with backoff on network errors, 408, 429 and 5xx, honouring `Retry-After`.
|
|
11
|
+
- Automatic `Idempotency-Key` on spending and outward writes, kept the same across retries.
|
|
12
|
+
- One iterator over every list style (cursor, offset, page, before).
|
|
13
|
+
- Typed exceptions for the API's error shapes; held writes come back as `PendingApproval`.
|
|
14
|
+
|
|
15
|
+
Python 3.9 or newer.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pip install hirefinn # not published yet; until then:
|
|
21
|
+
pip install git+https://github.com/Hire-FinnAI/finn-sdk-py.git
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The distribution is called `hirefinn` (the name `finn` on PyPI belongs to another project); you import `finn`.
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from finn import Finn, is_pending_approval
|
|
30
|
+
|
|
31
|
+
finn = Finn() # reads FINN_API_KEY; or Finn(api_key="finn_live_...")
|
|
32
|
+
|
|
33
|
+
# Place a call
|
|
34
|
+
call = finn.calls.create({"finn_id": "8f14e45f-...", "to_number": "4155550142", "country_code": "1"})
|
|
35
|
+
if is_pending_approval(call):
|
|
36
|
+
print("Waiting for a person to approve:", call.approve_url)
|
|
37
|
+
outcome = finn.wait_for_approval(call.approval_id)
|
|
38
|
+
else:
|
|
39
|
+
print(call["data"]["call_uuid"])
|
|
40
|
+
|
|
41
|
+
# Read one
|
|
42
|
+
c = finn.calls.retrieve(call_uuid)
|
|
43
|
+
print(c["data"]["status"])
|
|
44
|
+
|
|
45
|
+
# Every completed call, page by page under the hood
|
|
46
|
+
for summary in finn.calls.list_all(status="completed", limit=100):
|
|
47
|
+
print(summary["call_uuid"])
|
|
48
|
+
|
|
49
|
+
# CRM
|
|
50
|
+
rec = finn.contacts.create({"kind": "contact", "name": "Ada Lovelace", "phone": "+14155550142"})
|
|
51
|
+
finn.contacts.add_note(rec["data"]["id"], {"text": "Asked for a callback on Monday."})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Async:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import asyncio
|
|
58
|
+
from finn import AsyncFinn
|
|
59
|
+
|
|
60
|
+
async def main() -> None:
|
|
61
|
+
async with AsyncFinn() as finn:
|
|
62
|
+
agents = await finn.agents.list(limit=20)
|
|
63
|
+
async for conv in finn.chat.conversations.list_all():
|
|
64
|
+
print(conv["id"])
|
|
65
|
+
|
|
66
|
+
asyncio.run(main())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Use the client as a context manager (`with Finn() as finn:`) or call `finn.close()` to release connections.
|
|
70
|
+
|
|
71
|
+
## Client options
|
|
72
|
+
|
|
73
|
+
| Option | Default | |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `api_key` | env `FINN_API_KEY` | `finn_live_...` org key, `finn_test_...` test key, or OAuth `finn_oat_...` token |
|
|
76
|
+
| `base_url` | `https://api.hirefinn.ai/v1` | |
|
|
77
|
+
| `org` | none | `Finn-Org` header, for tokens that can use more than one org |
|
|
78
|
+
| `version` | none | Pins the API version (`Finn-Version` header), e.g. `"2026-11-01"` |
|
|
79
|
+
| `max_retries` | `2` | Retries after the first attempt |
|
|
80
|
+
| `timeout` | `60` | Seconds per attempt |
|
|
81
|
+
| `auto_idempotency` | `True` | Generate `Idempotency-Key` on spending/outward writes |
|
|
82
|
+
| `max_retry_after` | `60` | Longest `Retry-After` (seconds) to wait out; longer raises |
|
|
83
|
+
| `default_headers` | none | Sent on every request |
|
|
84
|
+
| `http_client` | new `httpx.Client` | Bring your own (proxies, custom transport); the SDK does not close it |
|
|
85
|
+
|
|
86
|
+
Every method also takes the per-request keyword options `idempotency_key`, `org`, `version`, `max_retries`,
|
|
87
|
+
`timeout` and `extra_headers`, which override the client's setting for that call only. `extra_headers` can never
|
|
88
|
+
replace `Authorization`.
|
|
89
|
+
|
|
90
|
+
## Resources
|
|
91
|
+
|
|
92
|
+
| Attribute | Methods |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| `calls` | `create`, `list`, `list_all`, `retrieve` |
|
|
95
|
+
| `finns` | `list`, `list_all`, `create`, `retrieve`, `update`, `delete`, `list_knowledge_base`, `add_knowledge_base_document`, `post_call_analysis` |
|
|
96
|
+
| `voices` | `list`, `list_all`, `retrieve` |
|
|
97
|
+
| `phone_numbers` | `list`, `list_all`, `search_available`, `rent`, `retrieve`, `release` |
|
|
98
|
+
| `audiences` | `list`, `list_all`, `create`, `retrieve`, `archive`, `list_contacts`, `list_all_contacts`, `add_contacts`, `remove_contact` |
|
|
99
|
+
| `deployments` | `list`, `list_all`, `create_outbound`, `create_inbound`, `retrieve`, `stop`, `stop_inbound`, `analytics` |
|
|
100
|
+
| `wallet` | `retrieve`, `list_transactions`, `list_all_transactions` |
|
|
101
|
+
| `do_not_call` | `list`, `list_all`, `add`, `remove` |
|
|
102
|
+
| `contacts` | `list`, `list_all`, `create`, `retrieve`, `update`, `timeline`, `timeline_all`, `add_note`, `list_kinds`, `list_fields` |
|
|
103
|
+
| `cases` | `list`, `list_all`, `retrieve`, `list_events`, `start` |
|
|
104
|
+
| `approvals` | `list`, `list_all`, `retrieve`, `decide`, `approve`, `reject`, `wait` |
|
|
105
|
+
| `agents` | `list`, `list_all`, `create`, `draft`, `retrieve`, `save_draft`, `validate`, `test`, `publish`, `drive_test` |
|
|
106
|
+
| `chat` | `list_channels`; `chat.conversations`: `list`, `list_all`, `start`, `retrieve`, `list_messages`, `list_all_messages`, `send`; `chat.contacts`: `list`, `list_all`, `retrieve`, `update` |
|
|
107
|
+
|
|
108
|
+
Path parameters are positional, the request body is the next positional argument (a dict), and query parameters
|
|
109
|
+
are keyword arguments (`from` is spelled `from_`). Knowledge-base uploads take `files=`:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
with open("policy.pdf", "rb") as f:
|
|
113
|
+
finn.finns.add_knowledge_base_document(finn_id, {"name": "Refund policy"}, files={"file": f})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Anything else, including endpoints newer than this SDK, goes through `request` (typed by method and path) or
|
|
117
|
+
`request_with_response` (untyped, returns status, headers and request id):
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
call = finn.request("get", "/calls/{call_uuid}", path_params={"call_uuid": call_uuid})
|
|
121
|
+
res = finn.request_with_response("get", "/wallet")
|
|
122
|
+
print(res.status, res.request_id, res.idempotent_replayed, res.data)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Retries
|
|
126
|
+
|
|
127
|
+
Failed attempts are retried up to `max_retries` times on network errors, 408, 429, 5xx and
|
|
128
|
+
`409 idempotency_in_progress`, waiting `Retry-After` when the API sends it and otherwise backing off exponentially
|
|
129
|
+
(0.5 s, 1 s, 2 s ... up to 8 s, with jitter). A `Retry-After` longer than `max_retry_after` raises at once.
|
|
130
|
+
Only requests that are safe to repeat are retried: `GET`, `PUT`, `DELETE`, and writes that carry an
|
|
131
|
+
`Idempotency-Key`.
|
|
132
|
+
|
|
133
|
+
## Idempotency keys
|
|
134
|
+
|
|
135
|
+
The SDK adds a random `Idempotency-Key` to `calls.create`, `phone_numbers.rent`, `deployments.create_outbound`,
|
|
136
|
+
`deployments.create_inbound`, `chat.conversations.start`, `chat.conversations.send`, `agents.create` and
|
|
137
|
+
`cases.start`, and keeps it across retries. Pass your own to make a retry from your side safe too, or `False` to
|
|
138
|
+
send none:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
finn.calls.create(body, idempotency_key=f"order-{order_id}")
|
|
142
|
+
finn.calls.create(body, idempotency_key=False)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Errors
|
|
146
|
+
|
|
147
|
+
Every API error raises `finn.FinnError` (or a subclass by status: `BadRequestError`, `AuthenticationError`,
|
|
148
|
+
`PaymentRequiredError`, `PermissionDeniedError`, `NotFoundError`, `ConflictError`, `PayloadTooLargeError`,
|
|
149
|
+
`RateLimitError`, `InternalServerError`). Branch on `code`, never on the message.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from finn import FinnError, NotFoundError
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
finn.finns.retrieve(finn_id)
|
|
156
|
+
except NotFoundError:
|
|
157
|
+
...
|
|
158
|
+
except FinnError as e:
|
|
159
|
+
print(e.status, e.code, e.message, e.request_id)
|
|
160
|
+
print(e.fields) # per-field problems on invalid_request
|
|
161
|
+
print(e.required_scope) # on insufficient_scope
|
|
162
|
+
print(e.action_url) # dashboard page that fixes it (insufficient_credits, ...)
|
|
163
|
+
print(e.retry_after, e.doc_url, e.details)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
No HTTP answer at all raises `FinnConnectionError` (`FinnTimeoutError` when an attempt timed out).
|
|
167
|
+
`FinnSdkError` is the base of everything the SDK raises.
|
|
168
|
+
|
|
169
|
+
## Approvals
|
|
170
|
+
|
|
171
|
+
Outward and spending writes from OAuth and MCP callers may be held for a person to approve. They answer HTTP 202,
|
|
172
|
+
which the SDK returns as a `PendingApproval` (`approval_id`, `approve_url`, `expires_at`, `action`, `summary`).
|
|
173
|
+
`finn.wait_for_approval(id)` polls until the approval is decided and returns it; for a held request, `status` ends
|
|
174
|
+
as `succeeded`, `failed`, `declined` or `expired`, and `result` holds the held request's own answer.
|
|
175
|
+
|
|
176
|
+
## Pagination
|
|
177
|
+
|
|
178
|
+
`list_all(...)` methods return a paginator: iterate it for items, `.pages()` for raw pages, `.to_list(limit)` to
|
|
179
|
+
collect. Async paginators work with `async for` and `await .to_list()`.
|
|
180
|
+
|
|
181
|
+
## Regenerating from the spec
|
|
182
|
+
|
|
183
|
+
The spec is vendored in `spec/openapi.json`. To refresh it from a local checkout of the backend (prod branch):
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
BACKEND=../hirefinn-web-app-backend ./scripts/pull-spec.sh
|
|
187
|
+
# or from a file or URL:
|
|
188
|
+
python scripts/generate.py path/or/url/to/openapi.json
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`scripts/generate.py` (standard library only) writes `finn/_generated/`: TypedDicts for every schema, the
|
|
192
|
+
resource classes (grouped by the `RESOURCES` table in the script) and typed overloads for `request()`. It fails if
|
|
193
|
+
the table names an operation the spec lacks, and lists spec operations the table does not cover.
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
199
|
+
.venv/bin/pytest
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The tests use `httpx.MockTransport` and never touch the network; one of them type-checks a sample with mypy.
|
|
203
|
+
|
|
204
|
+
Releases go to PyPI from a GitHub release via Trusted Publishing; see [RELEASING.md](RELEASING.md).
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Python SDK for the Finn API (https://api.hirefinn.ai/v1).
|
|
2
|
+
|
|
3
|
+
from finn import Finn
|
|
4
|
+
finn = Finn() # reads FINN_API_KEY
|
|
5
|
+
for call in finn.calls.list_all(status="completed"):
|
|
6
|
+
print(call["call_uuid"])
|
|
7
|
+
"""
|
|
8
|
+
from . import types
|
|
9
|
+
from ._approval import PendingApproval, is_pending_approval
|
|
10
|
+
from ._client import AsyncFinn, Finn
|
|
11
|
+
from ._core import AUTO_IDEMPOTENCY_ROUTES, DEFAULT_BASE_URL, idempotency_key
|
|
12
|
+
from ._errors import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
BadRequestError,
|
|
15
|
+
ConflictError,
|
|
16
|
+
FinnApprovalTimeoutError,
|
|
17
|
+
FinnConnectionError,
|
|
18
|
+
FinnError,
|
|
19
|
+
FinnFieldError,
|
|
20
|
+
FinnSdkError,
|
|
21
|
+
FinnTimeoutError,
|
|
22
|
+
InternalServerError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
PayloadTooLargeError,
|
|
25
|
+
PaymentRequiredError,
|
|
26
|
+
PermissionDeniedError,
|
|
27
|
+
RateLimitError,
|
|
28
|
+
code_for_status,
|
|
29
|
+
parse_retry_after,
|
|
30
|
+
)
|
|
31
|
+
from ._pagination import AsyncPaginator, SyncPaginator
|
|
32
|
+
from ._types import ApprovalHold, FinnResponse
|
|
33
|
+
from ._version import __version__
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AUTO_IDEMPOTENCY_ROUTES",
|
|
37
|
+
"DEFAULT_BASE_URL",
|
|
38
|
+
"ApprovalHold",
|
|
39
|
+
"AsyncFinn",
|
|
40
|
+
"AsyncPaginator",
|
|
41
|
+
"AuthenticationError",
|
|
42
|
+
"BadRequestError",
|
|
43
|
+
"ConflictError",
|
|
44
|
+
"Finn",
|
|
45
|
+
"FinnApprovalTimeoutError",
|
|
46
|
+
"FinnConnectionError",
|
|
47
|
+
"FinnError",
|
|
48
|
+
"FinnFieldError",
|
|
49
|
+
"FinnResponse",
|
|
50
|
+
"FinnSdkError",
|
|
51
|
+
"FinnTimeoutError",
|
|
52
|
+
"InternalServerError",
|
|
53
|
+
"NotFoundError",
|
|
54
|
+
"PayloadTooLargeError",
|
|
55
|
+
"PaymentRequiredError",
|
|
56
|
+
"PendingApproval",
|
|
57
|
+
"PermissionDeniedError",
|
|
58
|
+
"RateLimitError",
|
|
59
|
+
"SyncPaginator",
|
|
60
|
+
"__version__",
|
|
61
|
+
"code_for_status",
|
|
62
|
+
"idempotency_key",
|
|
63
|
+
"is_pending_approval",
|
|
64
|
+
"parse_retry_after",
|
|
65
|
+
"types",
|
|
66
|
+
]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""HTTP 202 ``pending_approval`` answers, and waiting for an approval to be decided."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional, Union
|
|
8
|
+
|
|
9
|
+
from ._errors import FinnApprovalTimeoutError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ._generated.schemas import Approval
|
|
13
|
+
from ._types import ApprovalHold
|
|
14
|
+
|
|
15
|
+
#: Statuses that are still waiting: holds (``pending``, ``running``) and Operator approvals (``open``).
|
|
16
|
+
WAITING_STATUSES = frozenset({"pending", "running", "open", "pending_approval"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class PendingApproval:
|
|
21
|
+
"""A write the API held for a person to approve (HTTP 202) instead of doing it.
|
|
22
|
+
|
|
23
|
+
Outward and spending actions from OAuth and MCP callers can answer this. Show ``approve_url`` to a person,
|
|
24
|
+
then ``client.wait_for_approval(approval_id)`` for the outcome.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
approval_id: str
|
|
28
|
+
#: The page in Finn where a person approves it.
|
|
29
|
+
approve_url: Optional[str]
|
|
30
|
+
expires_at: Optional[str] = None
|
|
31
|
+
action: Optional[str] = None
|
|
32
|
+
summary: Any = None
|
|
33
|
+
status: str = "pending_approval"
|
|
34
|
+
#: The full response body.
|
|
35
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_body(cls, body: Dict[str, Any]) -> "PendingApproval":
|
|
39
|
+
return cls(
|
|
40
|
+
approval_id=body["approval_id"],
|
|
41
|
+
approve_url=body.get("approve_url"),
|
|
42
|
+
expires_at=body.get("expires_at"),
|
|
43
|
+
action=body.get("action"),
|
|
44
|
+
summary=body.get("summary"),
|
|
45
|
+
raw=dict(body),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def looks_pending(body: Any) -> bool:
|
|
50
|
+
return isinstance(body, dict) and body.get("status") == "pending_approval" and isinstance(body.get("approval_id"), str)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_pending_approval(value: Any) -> bool:
|
|
54
|
+
"""True when a write was held for a person to approve (a PendingApproval, or its raw body)."""
|
|
55
|
+
return isinstance(value, PendingApproval) or looks_pending(value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def unwrap_approval(body: Any) -> Any:
|
|
59
|
+
"""GET /approvals/{id} answers the approval itself, or wraps it in ``data`` / ``approval``."""
|
|
60
|
+
if isinstance(body, dict):
|
|
61
|
+
for key in ("data", "approval"):
|
|
62
|
+
if isinstance(body.get(key), dict):
|
|
63
|
+
return body[key]
|
|
64
|
+
return body
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _status(approval: Any) -> Optional[str]:
|
|
68
|
+
s = approval.get("status") if isinstance(approval, dict) else None
|
|
69
|
+
return s if isinstance(s, str) else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
ApprovalResult = Union["Approval", "ApprovalHold"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ApprovalWait:
|
|
76
|
+
"""Adds wait() to the sync Approvals resource."""
|
|
77
|
+
|
|
78
|
+
if TYPE_CHECKING:
|
|
79
|
+
retrieve: Callable[..., ApprovalResult]
|
|
80
|
+
|
|
81
|
+
def wait(
|
|
82
|
+
self,
|
|
83
|
+
id: str,
|
|
84
|
+
*,
|
|
85
|
+
interval: float = 3.0,
|
|
86
|
+
timeout: float = 15 * 60.0,
|
|
87
|
+
on_poll: Optional[Callable[[ApprovalResult], None]] = None,
|
|
88
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
89
|
+
) -> ApprovalResult:
|
|
90
|
+
"""Polls ``GET /approvals/{id}`` until the approval leaves pending / running / open, and returns it.
|
|
91
|
+
|
|
92
|
+
For a held API request, ``status`` ends as ``succeeded``, ``failed``, ``declined`` or ``expired``, and
|
|
93
|
+
``result`` holds the held request's own answer. Raises FinnApprovalTimeoutError after ``timeout`` seconds.
|
|
94
|
+
"""
|
|
95
|
+
do_sleep = sleep or time.sleep
|
|
96
|
+
deadline = time.monotonic() + timeout
|
|
97
|
+
while True:
|
|
98
|
+
approval = self.retrieve(id)
|
|
99
|
+
if on_poll:
|
|
100
|
+
on_poll(approval)
|
|
101
|
+
last = _status(approval)
|
|
102
|
+
if last is not None and last not in WAITING_STATUSES:
|
|
103
|
+
return approval
|
|
104
|
+
if time.monotonic() + interval > deadline:
|
|
105
|
+
raise FinnApprovalTimeoutError(id, last)
|
|
106
|
+
do_sleep(interval)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AsyncApprovalWait:
|
|
110
|
+
"""Adds wait() to the async Approvals resource."""
|
|
111
|
+
|
|
112
|
+
if TYPE_CHECKING:
|
|
113
|
+
retrieve: Callable[..., Awaitable[ApprovalResult]]
|
|
114
|
+
|
|
115
|
+
async def wait(
|
|
116
|
+
self,
|
|
117
|
+
id: str,
|
|
118
|
+
*,
|
|
119
|
+
interval: float = 3.0,
|
|
120
|
+
timeout: float = 15 * 60.0,
|
|
121
|
+
on_poll: Optional[Callable[[ApprovalResult], None]] = None,
|
|
122
|
+
sleep: Optional[Callable[[float], Awaitable[None]]] = None,
|
|
123
|
+
) -> ApprovalResult:
|
|
124
|
+
"""Async wait(); see ApprovalWait.wait."""
|
|
125
|
+
do_sleep = sleep or asyncio.sleep
|
|
126
|
+
deadline = time.monotonic() + timeout
|
|
127
|
+
while True:
|
|
128
|
+
approval = await self.retrieve(id)
|
|
129
|
+
if on_poll:
|
|
130
|
+
on_poll(approval)
|
|
131
|
+
last = _status(approval)
|
|
132
|
+
if last is not None and last not in WAITING_STATUSES:
|
|
133
|
+
return approval
|
|
134
|
+
if time.monotonic() + interval > deadline:
|
|
135
|
+
raise FinnApprovalTimeoutError(id, last)
|
|
136
|
+
await do_sleep(interval)
|