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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv*/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .mypy_cache/
8
+ .pytest_cache/
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.
@@ -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).
@@ -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)