authpi-admin 0.3.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.
Files changed (40) hide show
  1. authpi_admin/__init__.py +55 -0
  2. authpi_admin/_version.py +5 -0
  3. authpi_admin/client.py +155 -0
  4. authpi_admin/errors.py +218 -0
  5. authpi_admin/generated/__init__.py +28 -0
  6. authpi_admin/generated/models.py +380 -0
  7. authpi_admin/generated/resources/__init__.py +0 -0
  8. authpi_admin/generated/resources/accounts.py +157 -0
  9. authpi_admin/generated/resources/agents.py +177 -0
  10. authpi_admin/generated/resources/api_keys.py +213 -0
  11. authpi_admin/generated/resources/approvals.py +109 -0
  12. authpi_admin/generated/resources/auth_methods.py +148 -0
  13. authpi_admin/generated/resources/clients.py +197 -0
  14. authpi_admin/generated/resources/deliveries.py +83 -0
  15. authpi_admin/generated/resources/domains.py +194 -0
  16. authpi_admin/generated/resources/events.py +111 -0
  17. authpi_admin/generated/resources/groups.py +160 -0
  18. authpi_admin/generated/resources/invitations.py +202 -0
  19. authpi_admin/generated/resources/issuers.py +157 -0
  20. authpi_admin/generated/resources/members.py +154 -0
  21. authpi_admin/generated/resources/notes.py +192 -0
  22. authpi_admin/generated/resources/organizations.py +244 -0
  23. authpi_admin/generated/resources/sessions.py +125 -0
  24. authpi_admin/generated/resources/tokens.py +94 -0
  25. authpi_admin/generated/resources/trusted_devices.py +102 -0
  26. authpi_admin/generated/resources/users.py +224 -0
  27. authpi_admin/generated/resources/verifiers.py +102 -0
  28. authpi_admin/generated/resources/webhooks.py +167 -0
  29. authpi_admin/generated/scopes/__init__.py +0 -0
  30. authpi_admin/generated/scopes/agent_scope.py +84 -0
  31. authpi_admin/generated/scopes/issuer_scope.py +160 -0
  32. authpi_admin/generated/scopes/user_scope.py +102 -0
  33. authpi_admin/generated/scopes/webhook_scope.py +84 -0
  34. authpi_admin/http_client.py +305 -0
  35. authpi_admin/pagination.py +41 -0
  36. authpi_admin/py.typed +0 -0
  37. authpi_admin/user_agent.py +21 -0
  38. authpi_admin-0.3.0.dist-info/METADATA +285 -0
  39. authpi_admin-0.3.0.dist-info/RECORD +40 -0
  40. authpi_admin-0.3.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,160 @@
1
+ """Generated scope class for IssuerScope — DO NOT EDIT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import cached_property
6
+ from typing import Any, TYPE_CHECKING
7
+
8
+ from authpi_admin.http_client import validate_path_segment as _validate_path_segment
9
+
10
+ if TYPE_CHECKING:
11
+ from authpi_admin.http_client import HttpClient
12
+ from authpi_admin.generated.resources.agents import AgentsResource
13
+ from authpi_admin.generated.resources.approvals import ApprovalsResource
14
+ from authpi_admin.generated.resources.auth_methods import AuthMethodsResource
15
+ from authpi_admin.generated.resources.clients import ClientsResource
16
+ from authpi_admin.generated.resources.organizations import OrganizationsResource
17
+ from authpi_admin.generated.resources.api_keys import ApiKeysResource
18
+ from authpi_admin.generated.resources.groups import GroupsResource
19
+ from authpi_admin.generated.resources.invitations import InvitationsResource
20
+ from authpi_admin.generated.resources.members import MembersResource
21
+ from authpi_admin.generated.resources.domains import DomainsResource
22
+ from authpi_admin.generated.resources.sessions import SessionsResource
23
+ from authpi_admin.generated.resources.users import UsersResource
24
+ from authpi_admin.generated.scopes.agent_scope import AgentScope
25
+ from authpi_admin.generated.scopes.user_scope import UserScope
26
+
27
+
28
+ class IssuerScope:
29
+ """Scope for operations under a specific issuer."""
30
+
31
+ def __init__(self, client: HttpClient, base_path: str, issuer_id: str) -> None:
32
+ _validate_path_segment(issuer_id, "issuer_id")
33
+ self._client = client
34
+ self._base_path = base_path
35
+ self._issuer_id = issuer_id
36
+ self._path = f"{base_path}/{issuer_id}"
37
+
38
+ @cached_property
39
+ def agents(self) -> AgentsResource:
40
+ """Access agents under this issuer."""
41
+ return AgentsResource(self._client, f"{self._path}/agents")
42
+
43
+ @cached_property
44
+ def approvals(self) -> ApprovalsResource:
45
+ """Access approvals under this issuer."""
46
+ return ApprovalsResource(self._client, f"{self._path}/approvals")
47
+
48
+ @cached_property
49
+ def auth_methods(self) -> AuthMethodsResource:
50
+ """Access auth_methods under this issuer."""
51
+ return AuthMethodsResource(self._client, f"{self._path}/auth-methods")
52
+
53
+ @cached_property
54
+ def clients(self) -> ClientsResource:
55
+ """Access clients under this issuer."""
56
+ return ClientsResource(self._client, f"{self._path}/clients")
57
+
58
+ @cached_property
59
+ def organizations(self) -> OrganizationsResource:
60
+ """Access organizations under this issuer."""
61
+ return OrganizationsResource(self._client, f"{self._path}/organizations")
62
+
63
+ @cached_property
64
+ def api_keys(self) -> ApiKeysResource:
65
+ """Access api_keys under this issuer."""
66
+ return ApiKeysResource(self._client, f"{self._path}/api-keys")
67
+
68
+ @cached_property
69
+ def groups(self) -> GroupsResource:
70
+ """Access groups under this issuer."""
71
+ return GroupsResource(self._client, f"{self._path}/groups")
72
+
73
+ @cached_property
74
+ def invitations(self) -> InvitationsResource:
75
+ """Access invitations under this issuer."""
76
+ return InvitationsResource(self._client, f"{self._path}/invitations")
77
+
78
+ @cached_property
79
+ def members(self) -> MembersResource:
80
+ """Access members under this issuer."""
81
+ return MembersResource(self._client, f"{self._path}/members")
82
+
83
+ @cached_property
84
+ def domains(self) -> DomainsResource:
85
+ """Access domains under this issuer."""
86
+ return DomainsResource(self._client, f"{self._path}/domains")
87
+
88
+ @cached_property
89
+ def sessions(self) -> SessionsResource:
90
+ """Access sessions under this issuer."""
91
+ return SessionsResource(self._client, f"{self._path}/sessions")
92
+
93
+ @cached_property
94
+ def users(self) -> UsersResource:
95
+ """Access users under this issuer."""
96
+ return UsersResource(self._client, f"{self._path}/users")
97
+
98
+ def agent(self, agent_id: str) -> AgentScope:
99
+ """Scope into a specific agent."""
100
+ return AgentScope(self._client, f"{self._path}/agents", agent_id)
101
+
102
+ def user(self, user_id: str) -> UserScope:
103
+ """Scope into a specific user."""
104
+ return UserScope(self._client, f"{self._path}/users", user_id)
105
+
106
+ async def get(
107
+ self,
108
+ *,
109
+ timeout: float | None = None,
110
+ headers: dict[str, str] | None = None,
111
+ ) -> Any:
112
+ """Get Issuer"""
113
+ resp = await self._client.request("GET", self._path, timeout=timeout, headers=headers)
114
+ return resp.data
115
+
116
+ async def update(
117
+ self,
118
+ body: dict[str, Any],
119
+ *,
120
+ if_match: str | None = None,
121
+ idempotency_key: str | None = None,
122
+ timeout: float | None = None,
123
+ headers: dict[str, str] | None = None,
124
+ ) -> Any:
125
+ """Update Issuer"""
126
+ req_headers: dict[str, str] = {}
127
+ if headers:
128
+ req_headers.update(headers)
129
+ if if_match:
130
+ req_headers["if-match"] = if_match
131
+ if idempotency_key:
132
+ req_headers["idempotency-key"] = idempotency_key
133
+ resp = await self._client.request(
134
+ "PATCH",
135
+ self._path,
136
+ body=body,
137
+ timeout=timeout,
138
+ headers=req_headers or None,
139
+ )
140
+ return resp.data
141
+
142
+ async def delete(
143
+ self,
144
+ *,
145
+ if_match: str | None = None,
146
+ timeout: float | None = None,
147
+ headers: dict[str, str] | None = None,
148
+ ) -> None:
149
+ """Delete Issuer"""
150
+ req_headers: dict[str, str] = {}
151
+ if headers:
152
+ req_headers.update(headers)
153
+ if if_match:
154
+ req_headers["if-match"] = if_match
155
+ await self._client.request(
156
+ "DELETE",
157
+ self._path,
158
+ timeout=timeout,
159
+ headers=req_headers or None,
160
+ )
@@ -0,0 +1,102 @@
1
+ """Generated scope class for UserScope — DO NOT EDIT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import cached_property
6
+ from typing import Any, TYPE_CHECKING
7
+
8
+ from authpi_admin.http_client import validate_path_segment as _validate_path_segment
9
+
10
+ if TYPE_CHECKING:
11
+ from authpi_admin.http_client import HttpClient
12
+ from authpi_admin.generated.resources.sessions import SessionsResource
13
+ from authpi_admin.generated.resources.tokens import TokensResource
14
+ from authpi_admin.generated.resources.trusted_devices import TrustedDevicesResource
15
+ from authpi_admin.generated.resources.verifiers import VerifiersResource
16
+
17
+
18
+ class UserScope:
19
+ """Scope for operations under a specific user."""
20
+
21
+ def __init__(self, client: HttpClient, base_path: str, user_id: str) -> None:
22
+ _validate_path_segment(user_id, "user_id")
23
+ self._client = client
24
+ self._base_path = base_path
25
+ self._user_id = user_id
26
+ self._path = f"{base_path}/{user_id}"
27
+
28
+ @cached_property
29
+ def sessions(self) -> SessionsResource:
30
+ """Access sessions under this user."""
31
+ return SessionsResource(self._client, f"{self._path}/sessions")
32
+
33
+ @cached_property
34
+ def tokens(self) -> TokensResource:
35
+ """Access tokens under this user."""
36
+ return TokensResource(self._client, f"{self._path}/tokens")
37
+
38
+ @cached_property
39
+ def trusted_devices(self) -> TrustedDevicesResource:
40
+ """Access trusted_devices under this user."""
41
+ return TrustedDevicesResource(self._client, f"{self._path}/trusted-devices")
42
+
43
+ @cached_property
44
+ def verifiers(self) -> VerifiersResource:
45
+ """Access verifiers under this user."""
46
+ return VerifiersResource(self._client, f"{self._path}/verifiers")
47
+
48
+ async def get(
49
+ self,
50
+ *,
51
+ timeout: float | None = None,
52
+ headers: dict[str, str] | None = None,
53
+ ) -> Any:
54
+ """Get User"""
55
+ resp = await self._client.request("GET", self._path, timeout=timeout, headers=headers)
56
+ return resp.data
57
+
58
+ async def update(
59
+ self,
60
+ body: dict[str, Any],
61
+ *,
62
+ if_match: str | None = None,
63
+ idempotency_key: str | None = None,
64
+ timeout: float | None = None,
65
+ headers: dict[str, str] | None = None,
66
+ ) -> Any:
67
+ """Update User"""
68
+ req_headers: dict[str, str] = {}
69
+ if headers:
70
+ req_headers.update(headers)
71
+ if if_match:
72
+ req_headers["if-match"] = if_match
73
+ if idempotency_key:
74
+ req_headers["idempotency-key"] = idempotency_key
75
+ resp = await self._client.request(
76
+ "PATCH",
77
+ self._path,
78
+ body=body,
79
+ timeout=timeout,
80
+ headers=req_headers or None,
81
+ )
82
+ return resp.data
83
+
84
+ async def delete(
85
+ self,
86
+ *,
87
+ if_match: str | None = None,
88
+ timeout: float | None = None,
89
+ headers: dict[str, str] | None = None,
90
+ ) -> None:
91
+ """Delete User"""
92
+ req_headers: dict[str, str] = {}
93
+ if headers:
94
+ req_headers.update(headers)
95
+ if if_match:
96
+ req_headers["if-match"] = if_match
97
+ await self._client.request(
98
+ "DELETE",
99
+ self._path,
100
+ timeout=timeout,
101
+ headers=req_headers or None,
102
+ )
@@ -0,0 +1,84 @@
1
+ """Generated scope class for WebhookScope — DO NOT EDIT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import cached_property
6
+ from typing import Any, TYPE_CHECKING
7
+
8
+ from authpi_admin.http_client import validate_path_segment as _validate_path_segment
9
+
10
+ if TYPE_CHECKING:
11
+ from authpi_admin.http_client import HttpClient
12
+ from authpi_admin.generated.resources.deliveries import DeliveriesResource
13
+
14
+
15
+ class WebhookScope:
16
+ """Scope for operations under a specific webhook."""
17
+
18
+ def __init__(self, client: HttpClient, base_path: str, webhook_id: str) -> None:
19
+ _validate_path_segment(webhook_id, "webhook_id")
20
+ self._client = client
21
+ self._base_path = base_path
22
+ self._webhook_id = webhook_id
23
+ self._path = f"{base_path}/{webhook_id}"
24
+
25
+ @cached_property
26
+ def deliveries(self) -> DeliveriesResource:
27
+ """Access deliveries under this webhook."""
28
+ return DeliveriesResource(self._client, f"{self._path}/deliveries")
29
+
30
+ async def get(
31
+ self,
32
+ *,
33
+ timeout: float | None = None,
34
+ headers: dict[str, str] | None = None,
35
+ ) -> Any:
36
+ """Get Webhook"""
37
+ resp = await self._client.request("GET", self._path, timeout=timeout, headers=headers)
38
+ return resp.data
39
+
40
+ async def update(
41
+ self,
42
+ body: dict[str, Any],
43
+ *,
44
+ if_match: str | None = None,
45
+ idempotency_key: str | None = None,
46
+ timeout: float | None = None,
47
+ headers: dict[str, str] | None = None,
48
+ ) -> Any:
49
+ """Update Webhook"""
50
+ req_headers: dict[str, str] = {}
51
+ if headers:
52
+ req_headers.update(headers)
53
+ if if_match:
54
+ req_headers["if-match"] = if_match
55
+ if idempotency_key:
56
+ req_headers["idempotency-key"] = idempotency_key
57
+ resp = await self._client.request(
58
+ "PATCH",
59
+ self._path,
60
+ body=body,
61
+ timeout=timeout,
62
+ headers=req_headers or None,
63
+ )
64
+ return resp.data
65
+
66
+ async def delete(
67
+ self,
68
+ *,
69
+ if_match: str | None = None,
70
+ timeout: float | None = None,
71
+ headers: dict[str, str] | None = None,
72
+ ) -> None:
73
+ """Delete Webhook"""
74
+ req_headers: dict[str, str] = {}
75
+ if headers:
76
+ req_headers.update(headers)
77
+ if if_match:
78
+ req_headers["if-match"] = if_match
79
+ await self._client.request(
80
+ "DELETE",
81
+ self._path,
82
+ timeout=timeout,
83
+ headers=req_headers or None,
84
+ )
@@ -0,0 +1,305 @@
1
+ """Internal HTTP client — not exported. Used by all resource classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from dataclasses import dataclass
8
+ from typing import Any, Awaitable, Callable
9
+
10
+ import httpx
11
+
12
+ from authpi_admin.errors import (
13
+ AuthenticationError,
14
+ ClosedClientError,
15
+ ConfigurationError,
16
+ error_from_response,
17
+ )
18
+ from authpi_admin._version import SDK_VERSION
19
+ from authpi_admin.user_agent import build_user_agent
20
+
21
+ _USER_AGENT = build_user_agent(SDK_VERSION)
22
+
23
+ # Maximum raw body size to store on errors (4KB)
24
+ _MAX_RAW_BODY = 4096
25
+
26
+ _UNSAFE_PATH_CHARS = frozenset("/\\?#")
27
+
28
+ # Headers that must not be overridden by user-provided values.
29
+ _PROTECTED_HEADERS = frozenset({"authorization", "host", "user-agent"})
30
+
31
+ _RETRYABLE_STATUSES = frozenset({429, 502, 503, 504})
32
+ _RETRYABLE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
33
+
34
+
35
+ def validate_path_segment(value: str, name: str = "id") -> str:
36
+ """Validate that a value is safe to interpolate into a URL path segment."""
37
+ if not value or any(c in value for c in _UNSAFE_PATH_CHARS) or value in (".", ".."):
38
+ raise ValueError(f"Invalid {name}: must not be empty or contain path separators")
39
+ return value
40
+
41
+
42
+ def _strip_underscore_keys(obj: Any) -> Any:
43
+ """Recursively remove keys starting with ``_`` from dicts (e.g. ``_etag``)."""
44
+ if obj is None or not isinstance(obj, (dict, list)):
45
+ return obj
46
+ if isinstance(obj, list):
47
+ return [_strip_underscore_keys(item) for item in obj]
48
+ return {k: _strip_underscore_keys(v) for k, v in obj.items() if not k.startswith("_")}
49
+
50
+
51
+ def _parse_retry_after(header: str | None) -> float | None:
52
+ """Parse Retry-After header value into seconds."""
53
+ if not header:
54
+ return None
55
+ try:
56
+ return float(header)
57
+ except ValueError:
58
+ return None
59
+
60
+
61
+ @dataclass
62
+ class RetryConfig:
63
+ """Retry configuration."""
64
+
65
+ limit: int = 3
66
+ delay: float = 1.0
67
+ backoff: str = "exponential" # "exponential" or "linear"
68
+
69
+
70
+ @dataclass
71
+ class ApiResponse:
72
+ """Response wrapper carrying parsed data and optional ETag."""
73
+
74
+ data: Any
75
+ etag: str | None = None
76
+
77
+
78
+ class HttpClient:
79
+ """Low-level HTTP client that handles auth, serialization, retries, and error mapping."""
80
+
81
+ __slots__ = (
82
+ "_base_url",
83
+ "_HttpClient__credential",
84
+ "_owns_client",
85
+ "_closed",
86
+ "_client",
87
+ "_default_headers",
88
+ "_retry_config",
89
+ "_on_token_expired",
90
+ "_refresh_lock",
91
+ )
92
+
93
+ def __init__(
94
+ self,
95
+ base_url: str,
96
+ *,
97
+ api_key: str | None = None,
98
+ access_token: str | None = None,
99
+ on_token_expired: Callable[[], Awaitable[dict[str, str]]] | None = None,
100
+ http_client: httpx.AsyncClient | None = None,
101
+ timeout: float = 30.0,
102
+ default_headers: dict[str, str] | None = None,
103
+ retries: bool | dict[str, Any] | None = None,
104
+ ) -> None:
105
+ has_api_key = api_key is not None
106
+ has_access_token = access_token is not None
107
+
108
+ if not has_api_key and not has_access_token:
109
+ raise ConfigurationError("Either api_key or access_token is required")
110
+ if has_api_key and has_access_token:
111
+ raise ConfigurationError("Provide either api_key or access_token, not both")
112
+
113
+ self._base_url = base_url.rstrip("/")
114
+ self.__credential = api_key or access_token # name-mangled
115
+ self._default_headers = default_headers or {}
116
+ self._on_token_expired = on_token_expired
117
+ self._refresh_lock = asyncio.Lock()
118
+ self._owns_client = http_client is None
119
+ self._closed = False
120
+
121
+ if retries is False:
122
+ self._retry_config: RetryConfig | None = None
123
+ else:
124
+ r = retries if isinstance(retries, dict) else {}
125
+ self._retry_config = RetryConfig(
126
+ limit=r.get("limit", 3),
127
+ delay=r.get("delay", 1.0),
128
+ backoff=r.get("backoff", "exponential"),
129
+ )
130
+
131
+ if http_client is not None:
132
+ self._client = http_client
133
+ else:
134
+ self._client = httpx.AsyncClient(timeout=timeout)
135
+
136
+ def _update_credential(self, token: str) -> None:
137
+ """Update the bearer credential after a token refresh."""
138
+ self.__credential = token
139
+
140
+ def __repr__(self) -> str:
141
+ return f"HttpClient(base_url={self._base_url!r})"
142
+
143
+ async def request(
144
+ self,
145
+ method: str,
146
+ path: str,
147
+ *,
148
+ body: Any = None,
149
+ query: dict[str, Any] | None = None,
150
+ headers: dict[str, str] | None = None,
151
+ timeout: float | None = None,
152
+ ) -> ApiResponse:
153
+ """Send an HTTP request and return the parsed response."""
154
+ return await self._request(method, path, body=body, query=query, headers=headers, timeout=timeout, is_refresh_retry=False)
155
+
156
+ async def _request(
157
+ self,
158
+ method: str,
159
+ path: str,
160
+ *,
161
+ body: Any = None,
162
+ query: dict[str, Any] | None = None,
163
+ headers: dict[str, str] | None = None,
164
+ timeout: float | None = None,
165
+ is_refresh_retry: bool = False,
166
+ ) -> ApiResponse:
167
+ if self._closed:
168
+ raise ClosedClientError()
169
+
170
+ url = f"{self._base_url}{path}"
171
+
172
+ filtered_query: dict[str, Any] | None = None
173
+ if query:
174
+ filtered_query = {k: v for k, v in query.items() if v is not None}
175
+
176
+ content = json.dumps(_strip_underscore_keys(body)).encode() if body is not None else None
177
+
178
+ can_retry = self._retry_config is not None and method.upper() in _RETRYABLE_METHODS
179
+ max_attempts = (self._retry_config.limit + 1) if can_retry and self._retry_config else 1
180
+
181
+ for attempt in range(max_attempts):
182
+ req_headers = self._build_headers(body is not None, headers)
183
+
184
+ kwargs: dict[str, Any] = {
185
+ "method": method,
186
+ "url": url,
187
+ "headers": req_headers,
188
+ }
189
+ if content is not None:
190
+ kwargs["content"] = content
191
+ if filtered_query:
192
+ kwargs["params"] = filtered_query
193
+ if timeout is not None:
194
+ kwargs["timeout"] = timeout
195
+
196
+ try:
197
+ response = await self._client.request(**kwargs)
198
+ except httpx.CloseError as err:
199
+ raise ClosedClientError() from err
200
+ except RuntimeError as err:
201
+ if "closed" in str(err).lower():
202
+ raise ClosedClientError() from err
203
+ raise
204
+
205
+ # Handle 401 with token refresh
206
+ if response.status_code == 401 and self._on_token_expired and not is_refresh_retry:
207
+ await self._singleflight_refresh(self.__credential)
208
+ return await self._request(
209
+ method, path, body=body, query=query, headers=headers,
210
+ timeout=timeout, is_refresh_retry=True,
211
+ )
212
+
213
+ if response.is_success:
214
+ return self._handle_success(response)
215
+
216
+ # Check if we should retry
217
+ if can_retry and response.status_code in _RETRYABLE_STATUSES and attempt < max_attempts - 1:
218
+ retry_after = _parse_retry_after(response.headers.get("retry-after"))
219
+ if retry_after is not None:
220
+ delay = retry_after
221
+ elif self._retry_config and self._retry_config.backoff == "exponential":
222
+ delay = self._retry_config.delay * (2 ** attempt)
223
+ elif self._retry_config:
224
+ delay = self._retry_config.delay * (attempt + 1)
225
+ else:
226
+ delay = 1.0
227
+ await asyncio.sleep(delay)
228
+ continue
229
+
230
+ # Not retryable or last attempt
231
+ self._handle_error(response)
232
+
233
+ # Unreachable
234
+ raise RuntimeError("unreachable")
235
+
236
+ async def _singleflight_refresh(self, stale_credential: str) -> str:
237
+ """Deduplicate concurrent token refresh calls.
238
+
239
+ Uses the lock + credential comparison pattern: if the credential has
240
+ already been updated by the time we acquire the lock, skip the refresh.
241
+ This prevents rotating refresh tokens from being consumed multiple
242
+ times under concurrency.
243
+ """
244
+ async with self._refresh_lock:
245
+ # If credential changed while we waited, another coroutine already refreshed
246
+ if self.__credential != stale_credential:
247
+ return self.__credential # type: ignore[return-value]
248
+
249
+ assert self._on_token_expired is not None
250
+ result = await self._on_token_expired()
251
+ self._update_credential(result["access_token"])
252
+ return result["access_token"]
253
+
254
+ def _build_headers(self, has_body: bool, extra: dict[str, str] | None) -> dict[str, str]:
255
+ req_headers: dict[str, str] = {
256
+ "authorization": f"Bearer {self.__credential}",
257
+ "accept": "application/json",
258
+ "user-agent": _USER_AGENT,
259
+ }
260
+ if has_body:
261
+ req_headers["content-type"] = "application/json"
262
+
263
+ for key, value in self._default_headers.items():
264
+ if key.lower() not in _PROTECTED_HEADERS:
265
+ req_headers[key] = value
266
+
267
+ if extra:
268
+ for key, value in extra.items():
269
+ if key.lower() not in _PROTECTED_HEADERS:
270
+ req_headers[key] = value
271
+
272
+ return req_headers
273
+
274
+ @staticmethod
275
+ def _handle_success(response: httpx.Response) -> ApiResponse:
276
+ if response.status_code == 204 or not response.content:
277
+ return ApiResponse(data=None)
278
+ data = response.json()
279
+ etag = response.headers.get("etag")
280
+ return ApiResponse(data=data, etag=etag)
281
+
282
+ @staticmethod
283
+ def _handle_error(response: httpx.Response) -> None:
284
+ raw_body: str | None = None
285
+ parsed_body: dict[str, Any] | None = None
286
+
287
+ try:
288
+ parsed_body = response.json()
289
+ except Exception:
290
+ raw_text = response.text
291
+ raw_body = raw_text[:_MAX_RAW_BODY] if raw_text else None
292
+
293
+ resp_headers = dict(response.headers)
294
+ raise error_from_response(
295
+ status_code=response.status_code,
296
+ body=parsed_body,
297
+ headers=resp_headers,
298
+ raw_body=raw_body,
299
+ )
300
+
301
+ async def close(self) -> None:
302
+ """Close the underlying HTTP client (only if owned by this instance)."""
303
+ if self._owns_client:
304
+ await self._client.aclose()
305
+ self._closed = self._owns_client
@@ -0,0 +1,41 @@
1
+ """Cursor-based pagination helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Awaitable, Callable
6
+ from dataclasses import dataclass
7
+ from typing import Generic, TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Page(Generic[T]):
14
+ """A single page of results from a paginated endpoint."""
15
+
16
+ data: list[T]
17
+ has_more: bool
18
+ next_cursor: str | None = None
19
+
20
+
21
+ async def auto_paginate(
22
+ fetcher: Callable[[str | None], Awaitable[Page[T]]],
23
+ ) -> AsyncIterator[T]:
24
+ """Automatically paginate through all results.
25
+
26
+ Args:
27
+ fetcher: An async function that takes an optional cursor and returns a Page.
28
+
29
+ Yields:
30
+ Individual items from each page.
31
+ """
32
+ cursor: str | None = None
33
+ while True:
34
+ page = await fetcher(cursor)
35
+ for item in page.data:
36
+ yield item
37
+ if not page.has_more:
38
+ break
39
+ cursor = page.next_cursor
40
+ if cursor is None:
41
+ break
authpi_admin/py.typed ADDED
File without changes