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.
@@ -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