truagents 0.0.0.dev0__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.
Files changed (56) hide show
  1. truagents/__init__.py +18 -0
  2. truagents/__version__.py +1 -0
  3. truagents/auth.py +293 -0
  4. truagents/client.py +304 -0
  5. truagents/errors.py +193 -0
  6. truagents/generated/__init__.py +8 -0
  7. truagents/generated/api/__init__.py +1 -0
  8. truagents/generated/api/email_unsubscribe/__init__.py +1 -0
  9. truagents/generated/api/email_unsubscribe/list_email_unsubscribes.py +290 -0
  10. truagents/generated/api/email_unsubscribe/push_email_unsubscribes.py +236 -0
  11. truagents/generated/api/o_auth/__init__.py +1 -0
  12. truagents/generated/api/o_auth/issue_o_auth_token.py +241 -0
  13. truagents/generated/api/sms_unsubscribe/__init__.py +1 -0
  14. truagents/generated/api/sms_unsubscribe/list_sms_unsubscribes.py +290 -0
  15. truagents/generated/api/sms_unsubscribe/push_sms_unsubscribes.py +232 -0
  16. truagents/generated/api/voice_unsubscribe/__init__.py +1 -0
  17. truagents/generated/api/voice_unsubscribe/list_phone_unsubscribes.py +298 -0
  18. truagents/generated/api/voice_unsubscribe/push_phone_unsubscribes.py +232 -0
  19. truagents/generated/client.py +282 -0
  20. truagents/generated/errors.py +16 -0
  21. truagents/generated/models/__init__.py +65 -0
  22. truagents/generated/models/email_skipped_item.py +83 -0
  23. truagents/generated/models/email_skipped_item_reason.py +11 -0
  24. truagents/generated/models/email_source_enum.py +11 -0
  25. truagents/generated/models/email_unsubscribe_batch_request.py +93 -0
  26. truagents/generated/models/email_unsubscribe_batch_response.py +119 -0
  27. truagents/generated/models/email_unsubscribe_item.py +50 -0
  28. truagents/generated/models/email_unsubscribe_list_response.py +113 -0
  29. truagents/generated/models/email_unsubscribe_record.py +98 -0
  30. truagents/generated/models/email_unsubscribe_updated_entry.py +79 -0
  31. truagents/generated/models/o_auth_client_credentials_request.py +87 -0
  32. truagents/generated/models/o_auth_client_credentials_request_grant_type.py +8 -0
  33. truagents/generated/models/o_auth_error_response.py +83 -0
  34. truagents/generated/models/o_auth_error_response_error.py +11 -0
  35. truagents/generated/models/o_auth_refresh_token_request.py +75 -0
  36. truagents/generated/models/o_auth_refresh_token_request_grant_type.py +8 -0
  37. truagents/generated/models/o_auth_token_response.py +97 -0
  38. truagents/generated/models/o_auth_token_response_token_type.py +8 -0
  39. truagents/generated/models/phone_skipped_item.py +83 -0
  40. truagents/generated/models/phone_skipped_item_reason.py +11 -0
  41. truagents/generated/models/phone_source_enum.py +11 -0
  42. truagents/generated/models/phone_unsubscribe_batch_request.py +92 -0
  43. truagents/generated/models/phone_unsubscribe_batch_response.py +118 -0
  44. truagents/generated/models/phone_unsubscribe_item.py +51 -0
  45. truagents/generated/models/phone_unsubscribe_list_response.py +111 -0
  46. truagents/generated/models/phone_unsubscribe_record.py +99 -0
  47. truagents/generated/models/phone_unsubscribe_updated_entry.py +79 -0
  48. truagents/generated/models/rest_error_response.py +74 -0
  49. truagents/generated/models/unauthorized_organization_error.py +90 -0
  50. truagents/generated/models/unauthorized_organization_error_error.py +8 -0
  51. truagents/generated/types.py +54 -0
  52. truagents/observability.py +70 -0
  53. truagents/retry.py +64 -0
  54. truagents-0.0.0.dev0.dist-info/METADATA +171 -0
  55. truagents-0.0.0.dev0.dist-info/RECORD +56 -0
  56. truagents-0.0.0.dev0.dist-info/WHEEL +4 -0
truagents/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ from truagents import errors
2
+ from truagents.__version__ import __version__
3
+ from truagents.auth import TokenManager
4
+ from truagents.client import AsyncClient, Client
5
+ from truagents.observability import Hooks, Request, Response
6
+ from truagents.retry import RetryPolicy
7
+
8
+ __all__ = [
9
+ "AsyncClient",
10
+ "Client",
11
+ "Hooks",
12
+ "Request",
13
+ "Response",
14
+ "RetryPolicy",
15
+ "TokenManager",
16
+ "__version__",
17
+ "errors",
18
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.0.0.dev0"
truagents/auth.py ADDED
@@ -0,0 +1,293 @@
1
+ """OAuth token acquisition, caching, and refresh.
2
+
3
+ `TokenManager` mints and rotates access tokens against the TruAgents
4
+ `/oauth/token` endpoint. It caches the current token in memory and refreshes it
5
+ `refresh_threshold_seconds` before the server-side expiry so that a subsequent
6
+ caller never blocks on a synchronous token round-trip inside the API request.
7
+
8
+ Concurrency:
9
+
10
+ - Sync path (`get_access_token`): `_thread_lock` is held for the entire mint
11
+ sequence including the blocking network POST. Cold-start callers all block
12
+ on lock acquisition; the first caller mints, subsequent callers observe the
13
+ fresh cache and return without a redundant round-trip.
14
+ - Async path (`get_access_token_async`): `_async_lock` is held end-to-end
15
+ including the `await`, so concurrent coroutines collapse to a single mint.
16
+ `_thread_lock` is only taken briefly for the initial cache read and the
17
+ final state swap — no `await` inside a `_thread_lock` critical section.
18
+
19
+ Recovery from family-revoke (RFC 6749 §5.2 `invalid_grant`) is automatic: if a
20
+ cached refresh token is rejected, the manager drops it and mints a fresh
21
+ `client_credentials` grant transparently. Only the second attempt's outcome
22
+ surfaces to the caller.
23
+
24
+ Observability: the `/oauth/token` response body is never handed to
25
+ `on_response` hooks verbatim — it is replaced with a static redaction sentinel
26
+ so partner hooks logging `Response.body` cannot leak `access_token` /
27
+ `refresh_token`. Status, headers, and timing still flow through unchanged.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import threading
34
+ import time
35
+ from dataclasses import dataclass
36
+
37
+ import httpx
38
+
39
+ from truagents import errors
40
+ from truagents.observability import Hooks, Request, Response
41
+
42
+ DEFAULT_REFRESH_THRESHOLD_SECONDS = 60.0
43
+ TOKEN_PATH = "/oauth/token"
44
+ _MINT_CLIENT_CREDENTIALS = "client_credentials"
45
+ _MINT_REFRESH_TOKEN = "refresh_token"
46
+ _REDACTED_TOKEN_BODY = b"<redacted: token endpoint response>"
47
+
48
+
49
+ @dataclass
50
+ class _TokenState:
51
+ access_token: str
52
+ refresh_token: str | None
53
+ expires_at: float
54
+
55
+
56
+ class TokenManager:
57
+ """Cache-plus-refresh manager for TruAgents OAuth tokens."""
58
+
59
+ def __init__(
60
+ self,
61
+ client_id: str,
62
+ client_secret: str,
63
+ *,
64
+ base_url: str = "https://api.truagents.com",
65
+ timeout: float = 30.0,
66
+ refresh_threshold_seconds: float = DEFAULT_REFRESH_THRESHOLD_SECONDS,
67
+ hooks: Hooks | None = None,
68
+ http_client: httpx.Client | None = None,
69
+ async_http_client: httpx.AsyncClient | None = None,
70
+ ) -> None:
71
+ self._client_id = client_id
72
+ self._client_secret = client_secret
73
+ self._base_url = base_url.rstrip("/")
74
+ self._timeout = timeout
75
+ self._refresh_threshold_seconds = refresh_threshold_seconds
76
+ self._hooks = hooks or Hooks()
77
+ self._state: _TokenState | None = None
78
+ self._thread_lock = threading.Lock()
79
+ self._async_lock = asyncio.Lock()
80
+ self._http_client_provided = http_client is not None
81
+ self._async_http_client_provided = async_http_client is not None
82
+ self._http_client = http_client
83
+ self._async_http_client = async_http_client
84
+
85
+ def get_access_token(self) -> str:
86
+ """Return a valid access token, minting or refreshing if needed.
87
+
88
+ `_thread_lock` is held for the entire mint sequence so concurrent
89
+ threads on a cold start do not each issue a redundant token POST.
90
+ Subsequent callers with a warm cache acquire the lock, observe a
91
+ fresh `_state`, and release without touching the network.
92
+ """
93
+ with self._thread_lock:
94
+ cached = self._peek_valid_access_token()
95
+ if cached is not None:
96
+ return cached
97
+ refresh_token = self._state.refresh_token if self._state is not None else None
98
+ if refresh_token is not None:
99
+ try:
100
+ state = self._issue_refresh(refresh_token)
101
+ except errors.InvalidGrant:
102
+ state = self._issue_client_credentials()
103
+ else:
104
+ state = self._issue_client_credentials()
105
+ self._state = state
106
+ return state.access_token
107
+
108
+ async def get_access_token_async(self) -> str:
109
+ """Return a valid access token via the async transport.
110
+
111
+ `_async_lock` serialises concurrent coroutines end-to-end so a
112
+ thundering herd of awaits collapses into one network round-trip.
113
+ `_thread_lock` is only taken briefly around state reads/writes and
114
+ never spans an `await`.
115
+ """
116
+ async with self._async_lock:
117
+ with self._thread_lock:
118
+ cached = self._peek_valid_access_token()
119
+ if cached is not None:
120
+ return cached
121
+ refresh_token = self._state.refresh_token if self._state is not None else None
122
+ if refresh_token is not None:
123
+ try:
124
+ state = await self._issue_refresh_async(refresh_token)
125
+ except errors.InvalidGrant:
126
+ state = await self._issue_client_credentials_async()
127
+ else:
128
+ state = await self._issue_client_credentials_async()
129
+ with self._thread_lock:
130
+ self._state = state
131
+ return state.access_token
132
+
133
+ def close(self) -> None:
134
+ if self._http_client is not None and not self._http_client_provided:
135
+ self._http_client.close()
136
+ self._http_client = None
137
+
138
+ async def aclose(self) -> None:
139
+ if self._async_http_client is not None and not self._async_http_client_provided:
140
+ await self._async_http_client.aclose()
141
+ self._async_http_client = None
142
+
143
+ def _expire_now(self) -> None:
144
+ """Test/demo escape hatch — force the next call to refresh or re-mint.
145
+
146
+ Not part of the public API. Partners never touch this in production;
147
+ `TokenManager` auto-refreshes at `refresh_threshold_seconds` before the
148
+ server-side expiry. Present so `examples/oauth_flow.py` and contract
149
+ tests can exercise the refresh path without waiting `expires_in`
150
+ seconds.
151
+ """
152
+ with self._thread_lock:
153
+ if self._state is None:
154
+ return
155
+ self._state.expires_at = 0.0
156
+
157
+ def _peek_valid_access_token(self) -> str | None:
158
+ """Read cached access token if still fresh. Caller holds `_thread_lock`."""
159
+ state = self._state
160
+ if state is None:
161
+ return None
162
+ if state.expires_at - time.time() < self._refresh_threshold_seconds:
163
+ return None
164
+ return state.access_token
165
+
166
+ def _get_or_create_sync_client(self) -> httpx.Client:
167
+ if self._http_client is None:
168
+ self._http_client = httpx.Client(timeout=self._timeout)
169
+ return self._http_client
170
+
171
+ def _get_or_create_async_client(self) -> httpx.AsyncClient:
172
+ if self._async_http_client is None:
173
+ self._async_http_client = httpx.AsyncClient(timeout=self._timeout)
174
+ return self._async_http_client
175
+
176
+ def _build_form(self, kind: str, *, refresh_token: str = "") -> dict[str, str]:
177
+ if kind == _MINT_REFRESH_TOKEN:
178
+ return {
179
+ "grant_type": _MINT_REFRESH_TOKEN,
180
+ "refresh_token": refresh_token,
181
+ }
182
+ return {
183
+ "grant_type": _MINT_CLIENT_CREDENTIALS,
184
+ "client_id": self._client_id,
185
+ "client_secret": self._client_secret,
186
+ }
187
+
188
+ def _issue_client_credentials(self) -> _TokenState:
189
+ body = self._post_token(self._build_form(_MINT_CLIENT_CREDENTIALS))
190
+ return self._state_from_body(body)
191
+
192
+ def _issue_refresh(self, refresh_token: str) -> _TokenState:
193
+ body = self._post_token(self._build_form(_MINT_REFRESH_TOKEN, refresh_token=refresh_token))
194
+ return self._state_from_body(body)
195
+
196
+ async def _issue_client_credentials_async(self) -> _TokenState:
197
+ body = await self._post_token_async(self._build_form(_MINT_CLIENT_CREDENTIALS))
198
+ return self._state_from_body(body)
199
+
200
+ async def _issue_refresh_async(self, refresh_token: str) -> _TokenState:
201
+ body = await self._post_token_async(
202
+ self._build_form(_MINT_REFRESH_TOKEN, refresh_token=refresh_token),
203
+ )
204
+ return self._state_from_body(body)
205
+
206
+ def _post_token(self, form: dict[str, str]) -> dict:
207
+ client = self._get_or_create_sync_client()
208
+ url = f"{self._base_url}{TOKEN_PATH}"
209
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
210
+ snapshot = Request(method="POST", url=url, headers=headers, body=None)
211
+ self._hooks.emit_request(snapshot)
212
+ start = time.time()
213
+ try:
214
+ response = client.post(url, data=form, headers=headers, timeout=self._timeout)
215
+ except httpx.TransportError as exc:
216
+ err = errors.NetworkError(exc)
217
+ self._hooks.emit_error(err, snapshot)
218
+ raise err from exc
219
+ elapsed_ms = (time.time() - start) * 1000.0
220
+ self._emit_response_snapshot(response, elapsed_ms)
221
+ return self._parse_or_raise(response, snapshot)
222
+
223
+ async def _post_token_async(self, form: dict[str, str]) -> dict:
224
+ client = self._get_or_create_async_client()
225
+ url = f"{self._base_url}{TOKEN_PATH}"
226
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
227
+ snapshot = Request(method="POST", url=url, headers=headers, body=None)
228
+ self._hooks.emit_request(snapshot)
229
+ start = time.time()
230
+ try:
231
+ response = await client.post(url, data=form, headers=headers, timeout=self._timeout)
232
+ except httpx.TransportError as exc:
233
+ err = errors.NetworkError(exc)
234
+ self._hooks.emit_error(err, snapshot)
235
+ raise err from exc
236
+ elapsed_ms = (time.time() - start) * 1000.0
237
+ self._emit_response_snapshot(response, elapsed_ms)
238
+ return self._parse_or_raise(response, snapshot)
239
+
240
+ def _emit_response_snapshot(self, response: httpx.Response, elapsed_ms: float) -> None:
241
+ self._hooks.emit_response(
242
+ Response(
243
+ status_code=response.status_code,
244
+ headers=dict(response.headers),
245
+ body=_REDACTED_TOKEN_BODY,
246
+ ),
247
+ elapsed_ms,
248
+ )
249
+
250
+ def _parse_or_raise(self, response: httpx.Response, snapshot: Request) -> dict:
251
+ if response.status_code != 200:
252
+ err = errors.classify_http_error(response, "oauth")
253
+ self._hooks.emit_error(err, snapshot)
254
+ raise err
255
+ try:
256
+ body = response.json()
257
+ except ValueError as exc:
258
+ err = errors.AuthError(200, "malformed_response", str(exc))
259
+ self._hooks.emit_error(err, snapshot)
260
+ raise err from exc
261
+ if not isinstance(body, dict):
262
+ err = errors.AuthError(200, "malformed_response", "token response was not a JSON object")
263
+ self._hooks.emit_error(err, snapshot)
264
+ raise err
265
+ return body
266
+
267
+ def _state_from_body(self, body: dict) -> _TokenState:
268
+ access_token = self._require_field(body, "access_token")
269
+ expires_in = self._require_field(body, "expires_in")
270
+ try:
271
+ expires_in_seconds = float(expires_in)
272
+ except (TypeError, ValueError) as exc:
273
+ raise errors.AuthError(
274
+ 200,
275
+ "malformed_response",
276
+ f"expires_in must be numeric, got {expires_in!r}",
277
+ ) from exc
278
+ refresh_token = body.get("refresh_token")
279
+ return _TokenState(
280
+ access_token=access_token,
281
+ refresh_token=refresh_token,
282
+ expires_at=time.time() + expires_in_seconds,
283
+ )
284
+
285
+ @staticmethod
286
+ def _require_field(body: dict, field_name: str) -> str:
287
+ if field_name not in body:
288
+ raise errors.AuthError(
289
+ 200,
290
+ "malformed_response",
291
+ f"token response missing '{field_name}' — got keys={list(body.keys())}",
292
+ )
293
+ return body[field_name]
truagents/client.py ADDED
@@ -0,0 +1,304 @@
1
+ """Partner-facing sync and async client wrappers.
2
+
3
+ Wrap `truagents.generated.AuthenticatedClient` and add:
4
+
5
+ - Auto-refreshing bearer-token injection via `TokenManager`.
6
+ - Retries for `NetworkError`, `ServerError`, and `RateLimited` following
7
+ `RetryPolicy` (exponential backoff for transport-level failures;
8
+ `Retry-After`-honouring, `retry_after_cap`-bounded delay for 429).
9
+ - A `User-Agent` header of the form `TruAgents-Python-SDK/{version}`.
10
+ - Observability hooks fired around every API request.
11
+ - Typed `truagents.errors.*` exceptions on non-2xx responses.
12
+
13
+ A `TokenExpired` on an API endpoint is NOT auto-remedied at this layer. A
14
+ mid-flight 401 signals credentials revoked externally; partners see the typed
15
+ exception (frozen contract per PRD).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import time
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ import httpx
25
+
26
+ from truagents import errors
27
+ from truagents.__version__ import __version__
28
+ from truagents.auth import TokenManager
29
+ from truagents.generated import AuthenticatedClient
30
+ from truagents.generated.api.email_unsubscribe import (
31
+ list_email_unsubscribes,
32
+ push_email_unsubscribes,
33
+ )
34
+ from truagents.generated.api.sms_unsubscribe import (
35
+ list_sms_unsubscribes,
36
+ push_sms_unsubscribes,
37
+ )
38
+ from truagents.generated.api.voice_unsubscribe import (
39
+ list_phone_unsubscribes,
40
+ push_phone_unsubscribes,
41
+ )
42
+ from truagents.observability import Hooks, Request, Response
43
+ from truagents.retry import DEFAULT_RETRY_POLICY, RetryPolicy
44
+
45
+ if TYPE_CHECKING: # pragma: no cover
46
+ from truagents.generated.models.email_unsubscribe_batch_request import (
47
+ EmailUnsubscribeBatchRequest,
48
+ )
49
+ from truagents.generated.models.email_unsubscribe_batch_response import (
50
+ EmailUnsubscribeBatchResponse,
51
+ )
52
+ from truagents.generated.models.email_unsubscribe_list_response import (
53
+ EmailUnsubscribeListResponse,
54
+ )
55
+ from truagents.generated.models.phone_unsubscribe_batch_request import (
56
+ PhoneUnsubscribeBatchRequest,
57
+ )
58
+ from truagents.generated.models.phone_unsubscribe_batch_response import (
59
+ PhoneUnsubscribeBatchResponse,
60
+ )
61
+ from truagents.generated.models.phone_unsubscribe_list_response import (
62
+ PhoneUnsubscribeListResponse,
63
+ )
64
+
65
+
66
+ USER_AGENT_TEMPLATE = "TruAgents-Python-SDK/{version}"
67
+ _AUTH_HEADER = "Authorization"
68
+
69
+
70
+ class _ClientBase:
71
+ """Shared configuration surface between `Client` and `AsyncClient`."""
72
+
73
+ def __init__(
74
+ self,
75
+ client_id: str,
76
+ client_secret: str,
77
+ *,
78
+ base_url: str = "https://api.truagents.com",
79
+ timeout: float = 30.0,
80
+ retry: RetryPolicy = DEFAULT_RETRY_POLICY,
81
+ hooks: Hooks | None = None,
82
+ token_manager: TokenManager | None = None,
83
+ ) -> None:
84
+ self._base_url = base_url.rstrip("/")
85
+ self._timeout = timeout
86
+ self._retry = retry
87
+ self._hooks = hooks or Hooks()
88
+ self._token_manager = token_manager or TokenManager(
89
+ client_id=client_id,
90
+ client_secret=client_secret,
91
+ base_url=self._base_url,
92
+ timeout=timeout,
93
+ hooks=self._hooks,
94
+ )
95
+ self._authenticated = AuthenticatedClient(
96
+ base_url=self._base_url,
97
+ token="",
98
+ timeout=httpx.Timeout(timeout),
99
+ headers={"User-Agent": self.user_agent},
100
+ )
101
+
102
+ @property
103
+ def user_agent(self) -> str:
104
+ return USER_AGENT_TEMPLATE.format(version=__version__)
105
+
106
+ def _request_snapshot(self, method_name: str, token: str) -> Request:
107
+ return Request(
108
+ method=method_name,
109
+ url=self._base_url,
110
+ headers={
111
+ "User-Agent": self.user_agent,
112
+ _AUTH_HEADER: f"Bearer {token[:12]}..." if token else "Bearer <none>",
113
+ },
114
+ body=None,
115
+ )
116
+
117
+ def _emit_response(self, response: Any, elapsed_ms: float) -> None:
118
+ self._hooks.emit_response(
119
+ Response(
120
+ status_code=int(response.status_code),
121
+ headers=dict(response.headers),
122
+ body=response.content,
123
+ ),
124
+ elapsed_ms,
125
+ )
126
+
127
+ def _to_httpx_response(self, response: Any) -> httpx.Response:
128
+ return httpx.Response(
129
+ status_code=int(response.status_code),
130
+ headers=dict(response.headers),
131
+ content=response.content,
132
+ )
133
+
134
+
135
+ class Client(_ClientBase):
136
+ """Synchronous client for the TruAgents Unsubscribe API."""
137
+
138
+ def list_email_unsubscribes(self, **params: Any) -> EmailUnsubscribeListResponse:
139
+ return self._call_resource_sync(
140
+ "list_email_unsubscribes",
141
+ lambda client: list_email_unsubscribes.sync_detailed(client=client, **params),
142
+ )
143
+
144
+ def push_email_unsubscribes(self, batch: EmailUnsubscribeBatchRequest) -> EmailUnsubscribeBatchResponse:
145
+ return self._call_resource_sync(
146
+ "push_email_unsubscribes",
147
+ lambda client: push_email_unsubscribes.sync_detailed(client=client, body=batch),
148
+ )
149
+
150
+ def list_sms_unsubscribes(self, **params: Any) -> PhoneUnsubscribeListResponse:
151
+ return self._call_resource_sync(
152
+ "list_sms_unsubscribes",
153
+ lambda client: list_sms_unsubscribes.sync_detailed(client=client, **params),
154
+ )
155
+
156
+ def push_sms_unsubscribes(self, batch: PhoneUnsubscribeBatchRequest) -> PhoneUnsubscribeBatchResponse:
157
+ return self._call_resource_sync(
158
+ "push_sms_unsubscribes",
159
+ lambda client: push_sms_unsubscribes.sync_detailed(client=client, body=batch),
160
+ )
161
+
162
+ def list_voice_unsubscribes(self, **params: Any) -> PhoneUnsubscribeListResponse:
163
+ return self._call_resource_sync(
164
+ "list_voice_unsubscribes",
165
+ lambda client: list_phone_unsubscribes.sync_detailed(client=client, **params),
166
+ )
167
+
168
+ def push_voice_unsubscribes(self, batch: PhoneUnsubscribeBatchRequest) -> PhoneUnsubscribeBatchResponse:
169
+ return self._call_resource_sync(
170
+ "push_voice_unsubscribes",
171
+ lambda client: push_phone_unsubscribes.sync_detailed(client=client, body=batch),
172
+ )
173
+
174
+ def close(self) -> None:
175
+ httpx_client = self._authenticated._client
176
+ if httpx_client is not None:
177
+ httpx_client.close()
178
+ self._token_manager.close()
179
+
180
+ def __enter__(self) -> Client:
181
+ return self
182
+
183
+ def __exit__(self, *exc: Any) -> None:
184
+ self.close()
185
+
186
+ def _call_resource_sync(self, method_name: str, invoke: Any) -> Any:
187
+ attempt = 0
188
+ while True:
189
+ attempt += 1
190
+ token = self._token_manager.get_access_token()
191
+ self._inject_bearer_sync(token)
192
+ snapshot = self._request_snapshot(method_name, token)
193
+ self._hooks.emit_request(snapshot)
194
+ start = time.time()
195
+ try:
196
+ response = invoke(self._authenticated)
197
+ except httpx.TransportError as exc:
198
+ err = errors.NetworkError(exc)
199
+ self._hooks.emit_error(err, snapshot)
200
+ if not self._retry.should_retry(err, attempt):
201
+ raise err from exc
202
+ time.sleep(self._retry.next_delay(err, attempt))
203
+ continue
204
+ elapsed_ms = (time.time() - start) * 1000.0
205
+ self._emit_response(response, elapsed_ms)
206
+ if 200 <= int(response.status_code) < 300:
207
+ return response.parsed
208
+ err = errors.classify_http_error(self._to_httpx_response(response), "api")
209
+ self._hooks.emit_error(err, snapshot)
210
+ if not self._retry.should_retry(err, attempt):
211
+ raise err
212
+ time.sleep(self._retry.next_delay(err, attempt))
213
+
214
+ def _inject_bearer_sync(self, token: str) -> None:
215
+ header_value = f"Bearer {token}"
216
+ client = self._authenticated.get_httpx_client()
217
+ client.headers[_AUTH_HEADER] = header_value
218
+ self._authenticated._headers[_AUTH_HEADER] = header_value
219
+
220
+
221
+ class AsyncClient(_ClientBase):
222
+ """Asynchronous client for the TruAgents Unsubscribe API."""
223
+
224
+ async def list_email_unsubscribes(self, **params: Any) -> EmailUnsubscribeListResponse:
225
+ return await self._call_resource_async(
226
+ "list_email_unsubscribes",
227
+ lambda client: list_email_unsubscribes.asyncio_detailed(client=client, **params),
228
+ )
229
+
230
+ async def push_email_unsubscribes(self, batch: EmailUnsubscribeBatchRequest) -> EmailUnsubscribeBatchResponse:
231
+ return await self._call_resource_async(
232
+ "push_email_unsubscribes",
233
+ lambda client: push_email_unsubscribes.asyncio_detailed(client=client, body=batch),
234
+ )
235
+
236
+ async def list_sms_unsubscribes(self, **params: Any) -> PhoneUnsubscribeListResponse:
237
+ return await self._call_resource_async(
238
+ "list_sms_unsubscribes",
239
+ lambda client: list_sms_unsubscribes.asyncio_detailed(client=client, **params),
240
+ )
241
+
242
+ async def push_sms_unsubscribes(self, batch: PhoneUnsubscribeBatchRequest) -> PhoneUnsubscribeBatchResponse:
243
+ return await self._call_resource_async(
244
+ "push_sms_unsubscribes",
245
+ lambda client: push_sms_unsubscribes.asyncio_detailed(client=client, body=batch),
246
+ )
247
+
248
+ async def list_voice_unsubscribes(self, **params: Any) -> PhoneUnsubscribeListResponse:
249
+ return await self._call_resource_async(
250
+ "list_voice_unsubscribes",
251
+ lambda client: list_phone_unsubscribes.asyncio_detailed(client=client, **params),
252
+ )
253
+
254
+ async def push_voice_unsubscribes(self, batch: PhoneUnsubscribeBatchRequest) -> PhoneUnsubscribeBatchResponse:
255
+ return await self._call_resource_async(
256
+ "push_voice_unsubscribes",
257
+ lambda client: push_phone_unsubscribes.asyncio_detailed(client=client, body=batch),
258
+ )
259
+
260
+ async def aclose(self) -> None:
261
+ httpx_client = self._authenticated._async_client
262
+ if httpx_client is not None:
263
+ await httpx_client.aclose()
264
+ await self._token_manager.aclose()
265
+
266
+ async def __aenter__(self) -> AsyncClient:
267
+ return self
268
+
269
+ async def __aexit__(self, *exc: Any) -> None:
270
+ await self.aclose()
271
+
272
+ async def _call_resource_async(self, method_name: str, invoke: Any) -> Any:
273
+ attempt = 0
274
+ while True:
275
+ attempt += 1
276
+ token = await self._token_manager.get_access_token_async()
277
+ self._inject_bearer_async(token)
278
+ snapshot = self._request_snapshot(method_name, token)
279
+ self._hooks.emit_request(snapshot)
280
+ start = time.time()
281
+ try:
282
+ response = await invoke(self._authenticated)
283
+ except httpx.TransportError as exc:
284
+ err = errors.NetworkError(exc)
285
+ self._hooks.emit_error(err, snapshot)
286
+ if not self._retry.should_retry(err, attempt):
287
+ raise err from exc
288
+ await asyncio.sleep(self._retry.next_delay(err, attempt))
289
+ continue
290
+ elapsed_ms = (time.time() - start) * 1000.0
291
+ self._emit_response(response, elapsed_ms)
292
+ if 200 <= int(response.status_code) < 300:
293
+ return response.parsed
294
+ err = errors.classify_http_error(self._to_httpx_response(response), "api")
295
+ self._hooks.emit_error(err, snapshot)
296
+ if not self._retry.should_retry(err, attempt):
297
+ raise err
298
+ await asyncio.sleep(self._retry.next_delay(err, attempt))
299
+
300
+ def _inject_bearer_async(self, token: str) -> None:
301
+ header_value = f"Bearer {token}"
302
+ client = self._authenticated.get_async_httpx_client()
303
+ client.headers[_AUTH_HEADER] = header_value
304
+ self._authenticated._headers[_AUTH_HEADER] = header_value