faxforagents 0.2.0__py3-none-any.whl
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.
- faxforagents/__init__.py +62 -0
- faxforagents/_http.py +304 -0
- faxforagents/async_client.py +1249 -0
- faxforagents/cli.py +436 -0
- faxforagents/client.py +1238 -0
- faxforagents/errors.py +110 -0
- faxforagents/models.py +1336 -0
- faxforagents/py.typed +0 -0
- faxforagents/webhooks.py +119 -0
- faxforagents-0.2.0.dist-info/METADATA +253 -0
- faxforagents-0.2.0.dist-info/RECORD +14 -0
- faxforagents-0.2.0.dist-info/WHEEL +4 -0
- faxforagents-0.2.0.dist-info/entry_points.txt +2 -0
- faxforagents-0.2.0.dist-info/licenses/LICENSE +21 -0
faxforagents/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Python client for Fax for Agents, https://faxforagents.com.
|
|
2
|
+
|
|
3
|
+
Fax for Agents is a fax API, a hosted MCP server and SDKs built so AI agents can send and receive
|
|
4
|
+
faxes. This package wraps every ``/v1`` operation of the REST API, with typed models, idempotency keys
|
|
5
|
+
on every mutating call, retries that honour ``Retry-After``, pagination helpers and the webhook
|
|
6
|
+
signature check.
|
|
7
|
+
|
|
8
|
+
from faxforagents import Client
|
|
9
|
+
|
|
10
|
+
with Client() as ffa: # reads FAXFORAGENTS_API_KEY
|
|
11
|
+
doc = ffa.documents.create(text="Hello from an agent.")
|
|
12
|
+
quote = ffa.quotes.create(document_id=doc.id, to="+15550000001")
|
|
13
|
+
fax = ffa.faxes.wait(ffa.faxes.create(quote_id=quote.id).id)
|
|
14
|
+
print(fax.state) # delivered, with fax.evidence
|
|
15
|
+
|
|
16
|
+
Documentation: https://faxforagents.com/docs. The API reference: https://faxforagents.github.io/openapi/.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
__version__ = "0.2.0"
|
|
22
|
+
|
|
23
|
+
from faxforagents.async_client import AsyncClient
|
|
24
|
+
from faxforagents.client import TERMINAL_STATES, Client
|
|
25
|
+
from faxforagents.errors import (
|
|
26
|
+
APIConnectionError,
|
|
27
|
+
APIError,
|
|
28
|
+
APITimeoutError,
|
|
29
|
+
AuthenticationError,
|
|
30
|
+
ConflictError,
|
|
31
|
+
FaxForAgentsError,
|
|
32
|
+
InvalidRequestError,
|
|
33
|
+
NotFoundError,
|
|
34
|
+
PaymentRequiredError,
|
|
35
|
+
RateLimitedError,
|
|
36
|
+
ScopeError,
|
|
37
|
+
ServerError,
|
|
38
|
+
)
|
|
39
|
+
from faxforagents.webhooks import SignatureError, WebhookEvent, parse_event, verify_webhook
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"APIConnectionError",
|
|
43
|
+
"APIError",
|
|
44
|
+
"APITimeoutError",
|
|
45
|
+
"AsyncClient",
|
|
46
|
+
"AuthenticationError",
|
|
47
|
+
"Client",
|
|
48
|
+
"ConflictError",
|
|
49
|
+
"FaxForAgentsError",
|
|
50
|
+
"InvalidRequestError",
|
|
51
|
+
"NotFoundError",
|
|
52
|
+
"PaymentRequiredError",
|
|
53
|
+
"RateLimitedError",
|
|
54
|
+
"ScopeError",
|
|
55
|
+
"ServerError",
|
|
56
|
+
"SignatureError",
|
|
57
|
+
"TERMINAL_STATES",
|
|
58
|
+
"WebhookEvent",
|
|
59
|
+
"__version__",
|
|
60
|
+
"parse_event",
|
|
61
|
+
"verify_webhook",
|
|
62
|
+
]
|
faxforagents/_http.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""The HTTP layer under both clients: one key, one base URL, idempotency keys, retries, the error envelope.
|
|
2
|
+
|
|
3
|
+
- Every mutating call (POST, PATCH, DELETE) carries an ``Idempotency-Key``. When the caller passes none,
|
|
4
|
+
a uuid4 is minted once per call and reused on every retry of that call, so a retried send can never
|
|
5
|
+
create a second fax.
|
|
6
|
+
- 429 and 5xx answers are retried up to ``max_retries`` times. ``Retry-After`` is honoured when present
|
|
7
|
+
(seconds or an HTTP date, capped at ``max_retry_after``). Otherwise the wait is exponential with full
|
|
8
|
+
jitter from ``backoff_base``. Connection errors and timeouts are retried the same way.
|
|
9
|
+
- An error status raises ``APIError`` or the subclass for that status (``errors.py``).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import email.utils
|
|
15
|
+
import os
|
|
16
|
+
import random
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from collections.abc import AsyncIterator, Iterator, Mapping
|
|
20
|
+
from typing import Any, TypeVar, overload
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
from faxforagents import __version__
|
|
26
|
+
from faxforagents.errors import APIConnectionError, APIError, APITimeoutError, error_for
|
|
27
|
+
|
|
28
|
+
DEFAULT_BASE_URL = "https://api.faxforagents.com"
|
|
29
|
+
ENV_KEY = "FAXFORAGENTS_API_KEY"
|
|
30
|
+
ENV_KEY_FALLBACK = "FFA_API_KEY"
|
|
31
|
+
ENV_URL = "FAXFORAGENTS_API_URL"
|
|
32
|
+
MUTATING = frozenset({"POST", "PATCH", "DELETE"})
|
|
33
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
34
|
+
|
|
35
|
+
M = TypeVar("M", bound=BaseModel)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_key(key: str | None) -> str:
|
|
39
|
+
value = (key or os.environ.get(ENV_KEY) or os.environ.get(ENV_KEY_FALLBACK) or "").strip()
|
|
40
|
+
if not value:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"no API key: pass api_key= or set {ENV_KEY}. Keys are created in the Fax for Agents dashboard "
|
|
43
|
+
"or with POST /v1/api-keys."
|
|
44
|
+
)
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_url(base_url: str | None) -> str:
|
|
49
|
+
return (base_url or os.environ.get(ENV_URL) or DEFAULT_BASE_URL).rstrip("/")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def retry_after_seconds(value: str | None, cap: float) -> float | None:
|
|
53
|
+
if not value:
|
|
54
|
+
return None
|
|
55
|
+
value = value.strip()
|
|
56
|
+
try:
|
|
57
|
+
return min(float(value), cap)
|
|
58
|
+
except ValueError:
|
|
59
|
+
pass
|
|
60
|
+
parsed = email.utils.parsedate_to_datetime(value) if value else None
|
|
61
|
+
if parsed is None:
|
|
62
|
+
return None
|
|
63
|
+
return max(0.0, min(parsed.timestamp() - time.time(), cap))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dump(body: Any) -> Any:
|
|
67
|
+
if isinstance(body, BaseModel):
|
|
68
|
+
return body.model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
69
|
+
if isinstance(body, dict):
|
|
70
|
+
return {k: v for k, v in body.items() if v is not None}
|
|
71
|
+
return body
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def clean_params(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
75
|
+
if not params:
|
|
76
|
+
return None
|
|
77
|
+
out = {k: v for k, v in params.items() if v is not None}
|
|
78
|
+
return out or None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Settings:
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
api_key: str | None,
|
|
85
|
+
base_url: str | None,
|
|
86
|
+
timeout: float,
|
|
87
|
+
max_retries: int,
|
|
88
|
+
backoff_base: float,
|
|
89
|
+
max_retry_after: float,
|
|
90
|
+
) -> None:
|
|
91
|
+
self.api_key = resolve_key(api_key)
|
|
92
|
+
self.base_url = resolve_url(base_url)
|
|
93
|
+
self.timeout = timeout
|
|
94
|
+
self.max_retries = max(0, max_retries)
|
|
95
|
+
self.backoff_base = backoff_base
|
|
96
|
+
self.max_retry_after = max_retry_after
|
|
97
|
+
|
|
98
|
+
def headers(self) -> dict[str, str]:
|
|
99
|
+
return {
|
|
100
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
101
|
+
"Accept": "application/json",
|
|
102
|
+
"User-Agent": f"faxforagents-python/{__version__}",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def wait_for(self, attempt: int, retry_after: str | None) -> float:
|
|
106
|
+
explicit = retry_after_seconds(retry_after, self.max_retry_after)
|
|
107
|
+
if explicit is not None:
|
|
108
|
+
return explicit
|
|
109
|
+
return random.uniform(0, self.backoff_base * (2**attempt)) # noqa: S311 (jitter, not security)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Call:
|
|
113
|
+
"""One request as the caller meant it, with the idempotency key fixed before the first attempt."""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
method: str,
|
|
118
|
+
path: str,
|
|
119
|
+
*,
|
|
120
|
+
params: Mapping[str, Any] | None = None,
|
|
121
|
+
json: Any = None,
|
|
122
|
+
content: bytes | None = None,
|
|
123
|
+
headers: Mapping[str, str] | None = None,
|
|
124
|
+
idempotency_key: str | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
self.method = method.upper()
|
|
127
|
+
self.path = path
|
|
128
|
+
self.params = clean_params(params)
|
|
129
|
+
self.json = dump(json) if json is not None else None
|
|
130
|
+
self.content = content
|
|
131
|
+
self.headers = dict(headers or {})
|
|
132
|
+
if self.method in MUTATING and "Idempotency-Key" not in self.headers:
|
|
133
|
+
self.headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
134
|
+
if content is not None and "Content-Type" not in self.headers:
|
|
135
|
+
self.headers["Content-Type"] = "application/octet-stream"
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def idempotency_key(self) -> str | None:
|
|
139
|
+
return self.headers.get("Idempotency-Key")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse(resp: httpx.Response, model: type[M] | None) -> Any:
|
|
143
|
+
if not resp.content:
|
|
144
|
+
return {} if model is None else model.model_validate({})
|
|
145
|
+
ctype = resp.headers.get("content-type", "")
|
|
146
|
+
if not ctype.startswith("application/json"):
|
|
147
|
+
return resp.content
|
|
148
|
+
data = resp.json()
|
|
149
|
+
return data if model is None else model.model_validate(data)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def raise_for(resp: httpx.Response, settings: Settings) -> APIError:
|
|
153
|
+
try:
|
|
154
|
+
payload = resp.json()
|
|
155
|
+
except ValueError:
|
|
156
|
+
payload = None
|
|
157
|
+
return error_for(
|
|
158
|
+
resp.status_code,
|
|
159
|
+
payload,
|
|
160
|
+
retry_after=retry_after_seconds(resp.headers.get("retry-after"), settings.max_retry_after),
|
|
161
|
+
text=resp.text,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def paginate(fetch: Any, params: dict[str, Any], key: str = "items") -> Iterator[Any]:
|
|
166
|
+
"""Follow ``next_cursor`` until the API returns none. ``fetch(params)`` returns a page model or dict."""
|
|
167
|
+
cursor: str | None = None
|
|
168
|
+
while True:
|
|
169
|
+
page = fetch({**params, "cursor": cursor})
|
|
170
|
+
data = page.model_dump(by_alias=True) if isinstance(page, BaseModel) else page
|
|
171
|
+
items = getattr(page, key, None) if isinstance(page, BaseModel) else data.get(key)
|
|
172
|
+
yield from items or []
|
|
173
|
+
cursor = data.get("next_cursor")
|
|
174
|
+
if not cursor:
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class SyncHttp:
|
|
179
|
+
def __init__(self, settings: Settings, transport: httpx.BaseTransport | None = None) -> None:
|
|
180
|
+
self.settings = settings
|
|
181
|
+
self._client = httpx.Client(
|
|
182
|
+
base_url=settings.base_url,
|
|
183
|
+
headers=settings.headers(),
|
|
184
|
+
timeout=settings.timeout,
|
|
185
|
+
transport=transport,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
self._client.close()
|
|
190
|
+
|
|
191
|
+
def send(self, call: Call) -> httpx.Response:
|
|
192
|
+
attempt = 0
|
|
193
|
+
while True:
|
|
194
|
+
try:
|
|
195
|
+
resp = self._client.request(
|
|
196
|
+
call.method,
|
|
197
|
+
call.path,
|
|
198
|
+
params=call.params,
|
|
199
|
+
json=call.json,
|
|
200
|
+
content=call.content,
|
|
201
|
+
headers=call.headers,
|
|
202
|
+
)
|
|
203
|
+
except httpx.TimeoutException as exc:
|
|
204
|
+
if attempt >= self.settings.max_retries:
|
|
205
|
+
raise APITimeoutError(f"{call.method} {call.path} timed out") from exc
|
|
206
|
+
time.sleep(self.settings.wait_for(attempt, None))
|
|
207
|
+
attempt += 1
|
|
208
|
+
continue
|
|
209
|
+
except httpx.HTTPError as exc:
|
|
210
|
+
if attempt >= self.settings.max_retries:
|
|
211
|
+
raise APIConnectionError(
|
|
212
|
+
f"{self.settings.base_url} not reachable ({type(exc).__name__})"
|
|
213
|
+
) from exc
|
|
214
|
+
time.sleep(self.settings.wait_for(attempt, None))
|
|
215
|
+
attempt += 1
|
|
216
|
+
continue
|
|
217
|
+
if resp.status_code in RETRY_STATUSES and attempt < self.settings.max_retries:
|
|
218
|
+
time.sleep(self.settings.wait_for(attempt, resp.headers.get("retry-after")))
|
|
219
|
+
attempt += 1
|
|
220
|
+
continue
|
|
221
|
+
if resp.status_code >= 400:
|
|
222
|
+
raise raise_for(resp, self.settings)
|
|
223
|
+
return resp
|
|
224
|
+
|
|
225
|
+
@overload
|
|
226
|
+
def request(self, method: str, path: str, *, model: type[M], **kw: Any) -> M: ...
|
|
227
|
+
|
|
228
|
+
@overload
|
|
229
|
+
def request(self, method: str, path: str, *, model: None = None, **kw: Any) -> Any: ...
|
|
230
|
+
|
|
231
|
+
def request(self, method: str, path: str, *, model: type[M] | None = None, **kw: Any) -> Any:
|
|
232
|
+
return parse(self.send(Call(method, path, **kw)), model)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class AsyncHttp:
|
|
236
|
+
def __init__(self, settings: Settings, transport: httpx.AsyncBaseTransport | None = None) -> None:
|
|
237
|
+
self.settings = settings
|
|
238
|
+
self._client = httpx.AsyncClient(
|
|
239
|
+
base_url=settings.base_url,
|
|
240
|
+
headers=settings.headers(),
|
|
241
|
+
timeout=settings.timeout,
|
|
242
|
+
transport=transport,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def close(self) -> None:
|
|
246
|
+
await self._client.aclose()
|
|
247
|
+
|
|
248
|
+
async def send(self, call: Call) -> httpx.Response:
|
|
249
|
+
import asyncio
|
|
250
|
+
|
|
251
|
+
attempt = 0
|
|
252
|
+
while True:
|
|
253
|
+
try:
|
|
254
|
+
resp = await self._client.request(
|
|
255
|
+
call.method,
|
|
256
|
+
call.path,
|
|
257
|
+
params=call.params,
|
|
258
|
+
json=call.json,
|
|
259
|
+
content=call.content,
|
|
260
|
+
headers=call.headers,
|
|
261
|
+
)
|
|
262
|
+
except httpx.TimeoutException as exc:
|
|
263
|
+
if attempt >= self.settings.max_retries:
|
|
264
|
+
raise APITimeoutError(f"{call.method} {call.path} timed out") from exc
|
|
265
|
+
await asyncio.sleep(self.settings.wait_for(attempt, None))
|
|
266
|
+
attempt += 1
|
|
267
|
+
continue
|
|
268
|
+
except httpx.HTTPError as exc:
|
|
269
|
+
if attempt >= self.settings.max_retries:
|
|
270
|
+
raise APIConnectionError(
|
|
271
|
+
f"{self.settings.base_url} not reachable ({type(exc).__name__})"
|
|
272
|
+
) from exc
|
|
273
|
+
await asyncio.sleep(self.settings.wait_for(attempt, None))
|
|
274
|
+
attempt += 1
|
|
275
|
+
continue
|
|
276
|
+
if resp.status_code in RETRY_STATUSES and attempt < self.settings.max_retries:
|
|
277
|
+
await asyncio.sleep(self.settings.wait_for(attempt, resp.headers.get("retry-after")))
|
|
278
|
+
attempt += 1
|
|
279
|
+
continue
|
|
280
|
+
if resp.status_code >= 400:
|
|
281
|
+
raise raise_for(resp, self.settings)
|
|
282
|
+
return resp
|
|
283
|
+
|
|
284
|
+
@overload
|
|
285
|
+
async def request(self, method: str, path: str, *, model: type[M], **kw: Any) -> M: ...
|
|
286
|
+
|
|
287
|
+
@overload
|
|
288
|
+
async def request(self, method: str, path: str, *, model: None = None, **kw: Any) -> Any: ...
|
|
289
|
+
|
|
290
|
+
async def request(self, method: str, path: str, *, model: type[M] | None = None, **kw: Any) -> Any:
|
|
291
|
+
return parse(await self.send(Call(method, path, **kw)), model)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def apaginate(fetch: Any, params: dict[str, Any], key: str = "items") -> AsyncIterator[Any]:
|
|
295
|
+
"""The async twin of ``paginate``: ``await fetch(**params)`` returns a page model."""
|
|
296
|
+
cursor: str | None = None
|
|
297
|
+
while True:
|
|
298
|
+
page = await fetch(**{**params, "cursor": cursor})
|
|
299
|
+
items = getattr(page, key, None) or []
|
|
300
|
+
for item in items:
|
|
301
|
+
yield item
|
|
302
|
+
cursor = getattr(page, "next_cursor", None)
|
|
303
|
+
if not cursor:
|
|
304
|
+
return
|