nextlabs-sdk 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.
Files changed (104) hide show
  1. nextlabs_sdk/__init__.py +35 -0
  2. nextlabs_sdk/_auth/__init__.py +2 -0
  3. nextlabs_sdk/_auth/_active_account/__init__.py +6 -0
  4. nextlabs_sdk/_auth/_active_account/_active_account.py +34 -0
  5. nextlabs_sdk/_auth/_active_account/_active_account_store.py +85 -0
  6. nextlabs_sdk/_auth/_cloudaz_auth.py +434 -0
  7. nextlabs_sdk/_auth/_pdp_auth.py +103 -0
  8. nextlabs_sdk/_auth/_refresh_token_policy.py +58 -0
  9. nextlabs_sdk/_auth/_static_token_auth.py +19 -0
  10. nextlabs_sdk/_auth/_token_cache/__init__.py +8 -0
  11. nextlabs_sdk/_auth/_token_cache/_cached_token.py +117 -0
  12. nextlabs_sdk/_auth/_token_cache/_file_token_cache.py +101 -0
  13. nextlabs_sdk/_auth/_token_cache/_null_token_cache.py +21 -0
  14. nextlabs_sdk/_auth/_token_cache/_token_cache.py +25 -0
  15. nextlabs_sdk/_cli/__init__.py +0 -0
  16. nextlabs_sdk/_cli/_account_menu.py +71 -0
  17. nextlabs_sdk/_cli/_account_preferences.py +40 -0
  18. nextlabs_sdk/_cli/_account_preferences_store.py +107 -0
  19. nextlabs_sdk/_cli/_account_resolver.py +82 -0
  20. nextlabs_sdk/_cli/_activity_logs_cmd.py +126 -0
  21. nextlabs_sdk/_cli/_app.py +137 -0
  22. nextlabs_sdk/_cli/_audit_logs_cmd.py +136 -0
  23. nextlabs_sdk/_cli/_auth_cmd.py +341 -0
  24. nextlabs_sdk/_cli/_binary_output.py +31 -0
  25. nextlabs_sdk/_cli/_bulk_ids.py +65 -0
  26. nextlabs_sdk/_cli/_cache_key.py +46 -0
  27. nextlabs_sdk/_cli/_client_factory.py +157 -0
  28. nextlabs_sdk/_cli/_component_types_cmd.py +245 -0
  29. nextlabs_sdk/_cli/_components_cmd.py +334 -0
  30. nextlabs_sdk/_cli/_context.py +23 -0
  31. nextlabs_sdk/_cli/_dashboard_cmd.py +151 -0
  32. nextlabs_sdk/_cli/_detail_renderers.py +49 -0
  33. nextlabs_sdk/_cli/_error_handler.py +100 -0
  34. nextlabs_sdk/_cli/_expiry_format.py +50 -0
  35. nextlabs_sdk/_cli/_logging_setup.py +27 -0
  36. nextlabs_sdk/_cli/_operators_cmd.py +64 -0
  37. nextlabs_sdk/_cli/_output.py +74 -0
  38. nextlabs_sdk/_cli/_output_format.py +18 -0
  39. nextlabs_sdk/_cli/_parsing.py +35 -0
  40. nextlabs_sdk/_cli/_payload_loader.py +85 -0
  41. nextlabs_sdk/_cli/_pdp_cmd.py +294 -0
  42. nextlabs_sdk/_cli/_policies_cmd.py +536 -0
  43. nextlabs_sdk/_cli/_reporter_audit_logs_cmd.py +39 -0
  44. nextlabs_sdk/_cli/_reports_cmd.py +388 -0
  45. nextlabs_sdk/_cli/_system_config_cmd.py +36 -0
  46. nextlabs_sdk/_cli/_tags_cmd.py +101 -0
  47. nextlabs_sdk/_cloudaz/__init__.py +109 -0
  48. nextlabs_sdk/_cloudaz/_activity_log_query_models.py +35 -0
  49. nextlabs_sdk/_cloudaz/_activity_logs_service.py +138 -0
  50. nextlabs_sdk/_cloudaz/_async_client.py +171 -0
  51. nextlabs_sdk/_cloudaz/_audit_log_models.py +55 -0
  52. nextlabs_sdk/_cloudaz/_audit_logs.py +99 -0
  53. nextlabs_sdk/_cloudaz/_client.py +176 -0
  54. nextlabs_sdk/_cloudaz/_component_models.py +221 -0
  55. nextlabs_sdk/_cloudaz/_component_search.py +328 -0
  56. nextlabs_sdk/_cloudaz/_component_type_models.py +125 -0
  57. nextlabs_sdk/_cloudaz/_component_type_search.py +237 -0
  58. nextlabs_sdk/_cloudaz/_component_types.py +136 -0
  59. nextlabs_sdk/_cloudaz/_components.py +175 -0
  60. nextlabs_sdk/_cloudaz/_dashboard.py +121 -0
  61. nextlabs_sdk/_cloudaz/_dashboard_models.py +70 -0
  62. nextlabs_sdk/_cloudaz/_models.py +30 -0
  63. nextlabs_sdk/_cloudaz/_operators.py +54 -0
  64. nextlabs_sdk/_cloudaz/_policies.py +379 -0
  65. nextlabs_sdk/_cloudaz/_policy_models.py +288 -0
  66. nextlabs_sdk/_cloudaz/_policy_search.py +284 -0
  67. nextlabs_sdk/_cloudaz/_report_models.py +252 -0
  68. nextlabs_sdk/_cloudaz/_reporter_audit_log_models.py +21 -0
  69. nextlabs_sdk/_cloudaz/_reporter_audit_logs.py +83 -0
  70. nextlabs_sdk/_cloudaz/_reports.py +578 -0
  71. nextlabs_sdk/_cloudaz/_response.py +102 -0
  72. nextlabs_sdk/_cloudaz/_search.py +216 -0
  73. nextlabs_sdk/_cloudaz/_system_config.py +30 -0
  74. nextlabs_sdk/_cloudaz/_system_config_models.py +20 -0
  75. nextlabs_sdk/_cloudaz/_tags.py +129 -0
  76. nextlabs_sdk/_config.py +18 -0
  77. nextlabs_sdk/_http_transport.py +288 -0
  78. nextlabs_sdk/_http_transport_logging.py +61 -0
  79. nextlabs_sdk/_http_transport_logging_async.py +35 -0
  80. nextlabs_sdk/_json_response.py +131 -0
  81. nextlabs_sdk/_logging.py +106 -0
  82. nextlabs_sdk/_pagination.py +93 -0
  83. nextlabs_sdk/_pdp/__init__.py +30 -0
  84. nextlabs_sdk/_pdp/_async_client.py +115 -0
  85. nextlabs_sdk/_pdp/_client.py +115 -0
  86. nextlabs_sdk/_pdp/_enums.py +20 -0
  87. nextlabs_sdk/_pdp/_json_serializer.py +309 -0
  88. nextlabs_sdk/_pdp/_request_models.py +73 -0
  89. nextlabs_sdk/_pdp/_response_decode.py +32 -0
  90. nextlabs_sdk/_pdp/_response_models.py +68 -0
  91. nextlabs_sdk/_pdp/_urns.py +28 -0
  92. nextlabs_sdk/_pdp/_xml_serializer.py +285 -0
  93. nextlabs_sdk/_retry_policy.py +83 -0
  94. nextlabs_sdk/_version.py +8 -0
  95. nextlabs_sdk/cloudaz/__init__.py +114 -0
  96. nextlabs_sdk/exceptions.py +132 -0
  97. nextlabs_sdk/pdp/__init__.py +33 -0
  98. nextlabs_sdk/py.typed +0 -0
  99. nextlabs_sdk-0.2.0.dist-info/METADATA +457 -0
  100. nextlabs_sdk-0.2.0.dist-info/RECORD +104 -0
  101. nextlabs_sdk-0.2.0.dist-info/WHEEL +5 -0
  102. nextlabs_sdk-0.2.0.dist-info/entry_points.txt +2 -0
  103. nextlabs_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
  104. nextlabs_sdk-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,35 @@
1
+ """nextlabs-sdk."""
2
+
3
+ from nextlabs_sdk._auth._cloudaz_auth import CloudAzAuth as CloudAzAuth
4
+ from nextlabs_sdk._auth._pdp_auth import PdpAuth as PdpAuth
5
+ from nextlabs_sdk._auth._static_token_auth import StaticTokenAuth as StaticTokenAuth
6
+ from nextlabs_sdk._auth._token_cache import (
7
+ CachedToken as CachedToken,
8
+ )
9
+ from nextlabs_sdk._auth._token_cache import (
10
+ FileTokenCache as FileTokenCache,
11
+ )
12
+ from nextlabs_sdk._auth._token_cache import (
13
+ NullTokenCache as NullTokenCache,
14
+ )
15
+ from nextlabs_sdk._auth._token_cache import (
16
+ TokenCache as TokenCache,
17
+ )
18
+ from nextlabs_sdk._cloudaz._async_client import (
19
+ AsyncCloudAzClient as AsyncCloudAzClient,
20
+ )
21
+ from nextlabs_sdk._cloudaz._client import CloudAzClient as CloudAzClient
22
+ from nextlabs_sdk._config import HttpConfig as HttpConfig
23
+ from nextlabs_sdk._config import RetryConfig as RetryConfig
24
+ from nextlabs_sdk._http_transport import (
25
+ create_async_http_client as create_async_http_client,
26
+ )
27
+ from nextlabs_sdk._http_transport import (
28
+ create_http_client as create_http_client,
29
+ )
30
+ from nextlabs_sdk._pagination import AsyncPaginator as AsyncPaginator
31
+ from nextlabs_sdk._pagination import PageResult as PageResult
32
+ from nextlabs_sdk._pagination import SyncPaginator as SyncPaginator
33
+ from nextlabs_sdk._pdp._async_client import AsyncPdpClient as AsyncPdpClient
34
+ from nextlabs_sdk._pdp._client import PdpClient as PdpClient
35
+ from nextlabs_sdk._version import __version__ as __version__
@@ -0,0 +1,2 @@
1
+ from nextlabs_sdk._auth._cloudaz_auth import CloudAzAuth as CloudAzAuth
2
+ from nextlabs_sdk._auth._pdp_auth import PdpAuth as PdpAuth
@@ -0,0 +1,6 @@
1
+ from nextlabs_sdk._auth._active_account._active_account import (
2
+ ActiveAccount as ActiveAccount,
3
+ )
4
+ from nextlabs_sdk._auth._active_account._active_account_store import (
5
+ ActiveAccountStore as ActiveAccountStore,
6
+ )
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class ActiveAccount:
8
+ """Identifiers of the cached account currently marked as active."""
9
+
10
+ base_url: str
11
+ username: str
12
+ client_id: str
13
+
14
+ def to_dict(self) -> dict[str, str]:
15
+ return {
16
+ "base_url": self.base_url,
17
+ "username": self.username,
18
+ "client_id": self.client_id,
19
+ }
20
+
21
+ @classmethod
22
+ def from_dict(cls, payload: dict[str, object]) -> ActiveAccount:
23
+ base_url = payload["base_url"]
24
+ username = payload["username"]
25
+ client_id = payload["client_id"]
26
+ if not (
27
+ isinstance(base_url, str)
28
+ and isinstance(username, str)
29
+ and isinstance(client_id, str)
30
+ ):
31
+ raise ValueError("ActiveAccount fields must be strings")
32
+ if not (base_url and username and client_id):
33
+ raise ValueError("ActiveAccount fields must be non-empty")
34
+ return cls(base_url=base_url, username=username, client_id=client_id)
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from nextlabs_sdk._auth._active_account._active_account import ActiveAccount
9
+
10
+ _FILE_MODE = 0o600
11
+ _DIR_MODE = 0o700
12
+ _FILENAME = "active_account.json"
13
+ _PACKAGE_DIR = "nextlabs-sdk"
14
+
15
+
16
+ def _default_path() -> Path:
17
+ override = os.environ.get("NEXTLABS_CACHE_DIR")
18
+ if override:
19
+ return Path(override) / _FILENAME
20
+
21
+ xdg = os.environ.get("XDG_CACHE_HOME")
22
+ if xdg:
23
+ return Path(xdg) / _PACKAGE_DIR / _FILENAME
24
+
25
+ return Path.home() / ".cache" / _PACKAGE_DIR / _FILENAME
26
+
27
+
28
+ class ActiveAccountStore:
29
+ """JSON-backed pointer to the currently active cached account."""
30
+
31
+ def __init__(self, *, path: Path | str | None = None) -> None:
32
+ self._path = _default_path() if path is None else Path(path)
33
+
34
+ @property
35
+ def path(self) -> Path:
36
+ return self._path
37
+
38
+ def load(self) -> ActiveAccount | None:
39
+ if not self._path.exists():
40
+ return None
41
+ try:
42
+ with self._path.open("r", encoding="utf-8") as fh:
43
+ loaded = json.load(fh)
44
+ except (OSError, json.JSONDecodeError):
45
+ return None
46
+ if not isinstance(loaded, dict):
47
+ return None
48
+ try:
49
+ return ActiveAccount.from_dict(loaded)
50
+ except (KeyError, TypeError, ValueError):
51
+ return None
52
+
53
+ def save(self, account: ActiveAccount) -> None:
54
+ directory = self._path.parent
55
+ directory.mkdir(parents=True, exist_ok=True)
56
+ os.chmod(directory, _DIR_MODE)
57
+
58
+ fd, tmp_name = tempfile.mkstemp(
59
+ prefix=".active-",
60
+ suffix=".tmp",
61
+ dir=str(directory),
62
+ )
63
+ try:
64
+ self._atomic_write(fd, tmp_name, account)
65
+ except Exception:
66
+ if os.path.exists(tmp_name):
67
+ os.unlink(tmp_name)
68
+ raise
69
+
70
+ def clear(self) -> None:
71
+ try:
72
+ self._path.unlink()
73
+ except FileNotFoundError:
74
+ return
75
+
76
+ def _atomic_write(
77
+ self,
78
+ fd: int,
79
+ tmp_name: str,
80
+ account: ActiveAccount,
81
+ ) -> None:
82
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
83
+ json.dump(account.to_dict(), fh)
84
+ os.chmod(tmp_name, _FILE_MODE)
85
+ os.replace(tmp_name, self._path)
@@ -0,0 +1,434 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ from collections.abc import Awaitable, Generator
6
+ from typing import Callable
7
+
8
+ import httpx
9
+
10
+ from nextlabs_sdk._auth._refresh_token_policy import RefreshDecision, decide
11
+ from nextlabs_sdk._auth._token_cache._cached_token import CachedToken
12
+ from nextlabs_sdk._auth._token_cache._null_token_cache import NullTokenCache
13
+ from nextlabs_sdk._auth._token_cache._token_cache import TokenCache
14
+ from nextlabs_sdk._json_response import decode_json_object, require_int, require_str
15
+ from nextlabs_sdk._logging import logger
16
+ from nextlabs_sdk.exceptions import AuthenticationError, RefreshTokenExpiredError
17
+
18
+ _EXPIRY_SAFETY_MARGIN = 60
19
+ _OK_STATUS = 200
20
+ _UNAUTHORIZED_STATUS = 401
21
+ _REDIRECT_MIN_STATUS = 300
22
+ _REDIRECT_MAX_STATUS = 399
23
+ _SPA_FRAGMENT = "#"
24
+ _LOCATION_HEADER = "location"
25
+ _RELOGIN_HINT = "Run `nextlabs auth login` to re-authenticate."
26
+ _SPA_REDIRECT_MSG = (
27
+ "Server redirected API call to SPA login page "
28
+ "(Location={location!r}) — access token was rejected. {hint}"
29
+ )
30
+ _HTTP_POST = "POST"
31
+ _FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
32
+ _INITIAL_EXPIRY_AT = float(0)
33
+
34
+ _MSG_LIFETIME_EXCEEDED = "Refresh token lifetime exceeded — re-login required. {hint}"
35
+ _MSG_SERVER_REJECTED = "Refresh token rejected by server — re-login required. {hint}"
36
+ _MSG_NO_CREDS = "No refresh token and no password available. {hint}"
37
+
38
+
39
+ def _spa_redirect_location(response: httpx.Response) -> str:
40
+ for hop in response.history:
41
+ location = hop.headers.get(_LOCATION_HEADER, "")
42
+ if _SPA_FRAGMENT in location:
43
+ return location
44
+ return response.headers.get(_LOCATION_HEADER, "")
45
+
46
+
47
+ def _is_spa_redirect(response: httpx.Response) -> bool:
48
+ for hop in response.history:
49
+ if _SPA_FRAGMENT in hop.headers.get(_LOCATION_HEADER, ""):
50
+ return True
51
+ status = response.status_code
52
+ if _REDIRECT_MIN_STATUS <= status <= _REDIRECT_MAX_STATUS:
53
+ return _SPA_FRAGMENT in response.headers.get(_LOCATION_HEADER, "")
54
+ return False
55
+
56
+
57
+ def _form_headers() -> dict[str, str]:
58
+ return {"Content-Type": _FORM_CONTENT_TYPE}
59
+
60
+
61
+ def _build_password_request(
62
+ *,
63
+ token_url: str,
64
+ username: str,
65
+ password: str | None,
66
+ client_id: str,
67
+ ) -> httpx.Request:
68
+ return httpx.Request(
69
+ method=_HTTP_POST,
70
+ url=token_url,
71
+ data={
72
+ "grant_type": "password",
73
+ "username": username,
74
+ "password": password,
75
+ "client_id": client_id,
76
+ },
77
+ headers=_form_headers(),
78
+ )
79
+
80
+
81
+ def _build_refresh_request(
82
+ *,
83
+ token_url: str,
84
+ refresh_token: str,
85
+ client_id: str,
86
+ ) -> httpx.Request:
87
+ return httpx.Request(
88
+ method=_HTTP_POST,
89
+ url=token_url,
90
+ data={
91
+ "grant_type": "refresh_token",
92
+ "refresh_token": refresh_token,
93
+ "client_id": client_id,
94
+ },
95
+ headers=_form_headers(),
96
+ )
97
+
98
+
99
+ def _raise_unless_password_available(
100
+ *,
101
+ password: str | None,
102
+ token_url: str,
103
+ message: str,
104
+ exc_cls: type[AuthenticationError],
105
+ warn: str | None,
106
+ ) -> None:
107
+ if password is not None:
108
+ return
109
+ if warn is not None:
110
+ logger.warning(warn)
111
+ raise exc_cls(
112
+ message.format(hint=_RELOGIN_HINT),
113
+ status_code=None,
114
+ response_body=None,
115
+ request_method=_HTTP_POST,
116
+ request_url=token_url,
117
+ )
118
+
119
+
120
+ def _handle_refresh_failure(
121
+ *,
122
+ decision: RefreshDecision,
123
+ password: str | None,
124
+ token_url: str,
125
+ ) -> None:
126
+ if decision is RefreshDecision.USE_REFRESH:
127
+ _raise_unless_password_available(
128
+ password=password,
129
+ token_url=token_url,
130
+ message=_MSG_SERVER_REJECTED,
131
+ exc_cls=RefreshTokenExpiredError,
132
+ warn="cloudaz auth: refresh token rejected by server"
133
+ " and no password available — re-login required",
134
+ )
135
+ elif decision is RefreshDecision.KNOWN_EXPIRED:
136
+ logger.debug("cloudaz auth: refresh skipped (known-expired)")
137
+ _raise_unless_password_available(
138
+ password=password,
139
+ token_url=token_url,
140
+ message=_MSG_LIFETIME_EXCEEDED,
141
+ exc_cls=RefreshTokenExpiredError,
142
+ warn="cloudaz auth: refresh token expired"
143
+ " and no password available — re-login required",
144
+ )
145
+ else:
146
+ _raise_unless_password_available(
147
+ password=password,
148
+ token_url=token_url,
149
+ message=_MSG_NO_CREDS,
150
+ exc_cls=AuthenticationError,
151
+ warn=None,
152
+ )
153
+
154
+
155
+ class CloudAzAuth(httpx.Auth):
156
+ """OIDC password grant auth for the CloudAz Console API.
157
+
158
+ Supports an optional pluggable :class:`TokenCache` backend. Expiry is
159
+ tracked as absolute UTC epoch seconds so that cached tokens survive
160
+ process restarts.
161
+
162
+ When ``refresh_token_lifetime`` is provided, the SDK records the
163
+ refresh token's absolute expiry at every successful token
164
+ acquisition and uses it to short-circuit re-auth once the lifetime
165
+ has elapsed — skipping a doomed HTTP round-trip and surfacing a
166
+ :class:`RefreshTokenExpiredError` (or falling back to the password
167
+ grant when a password is configured).
168
+ """
169
+
170
+ requires_request_body = False
171
+ requires_response_body = True
172
+
173
+ def __init__(
174
+ self,
175
+ token_url: str,
176
+ username: str,
177
+ password: str | None,
178
+ client_id: str,
179
+ *,
180
+ token_cache: TokenCache | None = None,
181
+ ) -> None:
182
+ self._token_url = token_url
183
+ self._username = username
184
+ self._password = password
185
+ self._client_id = client_id
186
+ self._cache: TokenCache = token_cache or NullTokenCache()
187
+ self._cache_key = f"{token_url}|{username}|{client_id}"
188
+ self._lock = threading.Lock()
189
+ self.refresh_token_lifetime: int | None = None
190
+
191
+ self._token: str | None = None
192
+ self._refresh_token: str | None = None
193
+ self._refresh_expires_at: float | None = None
194
+ self._expires_at: float = _INITIAL_EXPIRY_AT
195
+
196
+ cached = self._cache.load(self._cache_key)
197
+ if cached is not None:
198
+ self._refresh_token = cached.refresh_token
199
+ self._refresh_expires_at = cached.refresh_expires_at
200
+ if not cached.is_expired(
201
+ now=time.time(),
202
+ safety_margin=_EXPIRY_SAFETY_MARGIN,
203
+ ):
204
+ self._token = cached.access_token
205
+ self._expires_at = cached.expires_at
206
+
207
+ def auth_flow(
208
+ self,
209
+ request: httpx.Request,
210
+ ) -> Generator[httpx.Request, httpx.Response, None]:
211
+ if not self._has_valid_token():
212
+ yield from self._reauthenticate()
213
+
214
+ request.headers["Authorization"] = f"Bearer {self._token}"
215
+ response = yield request
216
+
217
+ if response.status_code == _UNAUTHORIZED_STATUS or _is_spa_redirect(response):
218
+ yield from self._reauthenticate()
219
+ request.headers["Authorization"] = f"Bearer {self._token}"
220
+ retried = yield request
221
+ if _is_spa_redirect(retried):
222
+ raise AuthenticationError(
223
+ _SPA_REDIRECT_MSG.format(
224
+ location=_spa_redirect_location(retried),
225
+ hint=_RELOGIN_HINT,
226
+ ),
227
+ status_code=retried.status_code,
228
+ response_body=None,
229
+ request_method=request.method,
230
+ request_url=str(request.url),
231
+ )
232
+
233
+ def ensure_token(
234
+ self,
235
+ send: Callable[[httpx.Request], httpx.Response],
236
+ ) -> None:
237
+ """Fetch and cache a token synchronously via a provided transport.
238
+
239
+ Intended for explicit `authenticate()` flows that need to acquire a
240
+ token without making a password HTTP call. No-op when a valid token
241
+ is already available in memory.
242
+ """
243
+ if self._has_valid_token():
244
+ return
245
+ if self._try_refresh_sync(send):
246
+ return
247
+ logger.debug("cloudaz auth: falling back to password grant")
248
+ response = send(
249
+ _build_password_request(
250
+ token_url=self._token_url,
251
+ username=self._username,
252
+ password=self._password,
253
+ client_id=self._client_id,
254
+ ),
255
+ )
256
+ self._parse_token_response(response)
257
+
258
+ async def ensure_token_async(
259
+ self,
260
+ send: Callable[[httpx.Request], Awaitable[httpx.Response]],
261
+ ) -> None:
262
+ """Async counterpart of :meth:`ensure_token`."""
263
+ if self._has_valid_token():
264
+ return
265
+ if await self._try_refresh_async(send):
266
+ return
267
+ logger.debug("cloudaz auth: falling back to password grant")
268
+ response = await send(
269
+ _build_password_request(
270
+ token_url=self._token_url,
271
+ username=self._username,
272
+ password=self._password,
273
+ client_id=self._client_id,
274
+ ),
275
+ )
276
+ self._parse_token_response(response)
277
+
278
+ def _refresh_decision(self) -> RefreshDecision:
279
+ if self._refresh_token is None:
280
+ return RefreshDecision.ABSENT
281
+ return decide(
282
+ refresh_token=self._refresh_token,
283
+ refresh_expires_at=self._refresh_expires_at,
284
+ now=time.time(),
285
+ )
286
+
287
+ def _has_valid_token(self) -> bool:
288
+ with self._lock:
289
+ return self._token is not None and time.time() < self._expires_at
290
+
291
+ def _reauthenticate(self) -> Generator[httpx.Request, httpx.Response, None]:
292
+ decision = self._refresh_decision()
293
+ if decision is RefreshDecision.USE_REFRESH:
294
+ logger.debug("cloudaz auth: refresh attempt starting")
295
+ assert self._refresh_token is not None
296
+ response = yield _build_refresh_request(
297
+ token_url=self._token_url,
298
+ refresh_token=self._refresh_token,
299
+ client_id=self._client_id,
300
+ )
301
+ if response.status_code == _OK_STATUS:
302
+ self._parse_token_response(response)
303
+ logger.debug("cloudaz auth: refresh succeeded")
304
+ return
305
+ _handle_refresh_failure(
306
+ decision=decision, password=self._password, token_url=self._token_url
307
+ )
308
+ else:
309
+ _handle_refresh_failure(
310
+ decision=decision, password=self._password, token_url=self._token_url
311
+ )
312
+
313
+ logger.debug("cloudaz auth: falling back to password grant")
314
+ response = yield _build_password_request(
315
+ token_url=self._token_url,
316
+ username=self._username,
317
+ password=self._password,
318
+ client_id=self._client_id,
319
+ )
320
+ self._parse_token_response(response)
321
+
322
+ def _try_refresh_sync(
323
+ self,
324
+ send: Callable[[httpx.Request], httpx.Response],
325
+ ) -> bool:
326
+ decision = self._refresh_decision()
327
+ if decision is RefreshDecision.USE_REFRESH:
328
+ logger.debug("cloudaz auth: refresh attempt starting")
329
+ assert self._refresh_token is not None
330
+ response = send(
331
+ _build_refresh_request(
332
+ token_url=self._token_url,
333
+ refresh_token=self._refresh_token,
334
+ client_id=self._client_id,
335
+ ),
336
+ )
337
+ if response.status_code == _OK_STATUS:
338
+ self._parse_token_response(response)
339
+ logger.debug("cloudaz auth: refresh succeeded")
340
+ return True
341
+ _handle_refresh_failure(
342
+ decision=decision, password=self._password, token_url=self._token_url
343
+ )
344
+ return False
345
+
346
+ async def _try_refresh_async(
347
+ self,
348
+ send: Callable[[httpx.Request], Awaitable[httpx.Response]],
349
+ ) -> bool:
350
+ decision = self._refresh_decision()
351
+ if decision is RefreshDecision.USE_REFRESH:
352
+ logger.debug("cloudaz auth: refresh attempt starting")
353
+ assert self._refresh_token is not None
354
+ response = await send(
355
+ _build_refresh_request(
356
+ token_url=self._token_url,
357
+ refresh_token=self._refresh_token,
358
+ client_id=self._client_id,
359
+ ),
360
+ )
361
+ if response.status_code == _OK_STATUS:
362
+ self._parse_token_response(response)
363
+ logger.debug("cloudaz auth: refresh succeeded")
364
+ return True
365
+ _handle_refresh_failure(
366
+ decision=decision, password=self._password, token_url=self._token_url
367
+ )
368
+ return False
369
+
370
+ def _parse_token_response(
371
+ self,
372
+ response: httpx.Response,
373
+ ) -> None:
374
+ if response.status_code != _OK_STATUS:
375
+ raise AuthenticationError(
376
+ f"Token acquisition failed: HTTP {response.status_code}",
377
+ status_code=response.status_code,
378
+ response_body=response.text,
379
+ request_method=_HTTP_POST,
380
+ request_url=self._token_url,
381
+ )
382
+
383
+ body = decode_json_object(
384
+ response,
385
+ error_cls=AuthenticationError,
386
+ context=" in token response",
387
+ )
388
+ expires_in = require_int(
389
+ body,
390
+ "expires_in",
391
+ error_cls=AuthenticationError,
392
+ context=" in token response",
393
+ )
394
+ now = time.time()
395
+ expires_at = now + expires_in - _EXPIRY_SAFETY_MARGIN
396
+ access_token = require_str(
397
+ body,
398
+ "access_token",
399
+ error_cls=AuthenticationError,
400
+ context=" in token response",
401
+ )
402
+ refresh_token_raw = body.get("refresh_token")
403
+ refresh_token = (
404
+ refresh_token_raw
405
+ if isinstance(refresh_token_raw, str)
406
+ else self._refresh_token
407
+ )
408
+ token_type_raw = body.get("token_type", "bearer")
409
+ token_type = token_type_raw if isinstance(token_type_raw, str) else "bearer"
410
+ scope_raw = body.get("scope")
411
+ scope = scope_raw if isinstance(scope_raw, str) else None
412
+ refresh_expires_at = (
413
+ None
414
+ if self.refresh_token_lifetime is None
415
+ else now + self.refresh_token_lifetime
416
+ )
417
+
418
+ with self._lock:
419
+ self._token = access_token
420
+ self._refresh_token = refresh_token
421
+ self._expires_at = expires_at
422
+ self._refresh_expires_at = refresh_expires_at
423
+
424
+ self._cache.save(
425
+ self._cache_key,
426
+ CachedToken(
427
+ access_token=access_token,
428
+ refresh_token=refresh_token,
429
+ expires_at=expires_at,
430
+ token_type=token_type,
431
+ scope=scope,
432
+ refresh_expires_at=refresh_expires_at,
433
+ ),
434
+ )
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ from collections.abc import Generator
6
+
7
+ import httpx
8
+
9
+ from nextlabs_sdk._json_response import decode_json_object, require_int, require_str
10
+ from nextlabs_sdk.exceptions import AuthenticationError
11
+
12
+ _EXPIRY_SAFETY_MARGIN = 60
13
+ _OK_STATUS = 200
14
+ _UNAUTHORIZED_STATUS = 401
15
+
16
+
17
+ class PdpAuth(httpx.Auth):
18
+ """OAuth2 client credentials auth for the PDP REST API."""
19
+
20
+ requires_request_body = False
21
+ requires_response_body = True
22
+
23
+ def __init__(
24
+ self,
25
+ token_url: str,
26
+ client_id: str,
27
+ client_secret: str,
28
+ ) -> None:
29
+ self._token_url = token_url
30
+ self._client_id = client_id
31
+ self._client_secret = client_secret
32
+ self._token: str | None = None
33
+ self._token_expiry: float = 0
34
+ self._lock = threading.Lock()
35
+
36
+ def auth_flow(
37
+ self,
38
+ request: httpx.Request,
39
+ ) -> Generator[httpx.Request, httpx.Response, None]:
40
+ if not self._has_valid_token():
41
+ token_response = yield self._build_token_request()
42
+ self._parse_token_response(token_response)
43
+
44
+ request.headers["Authorization"] = f"Bearer {self._token}"
45
+ response = yield request
46
+
47
+ if response.status_code == _UNAUTHORIZED_STATUS:
48
+ token_response = yield self._build_token_request()
49
+ self._parse_token_response(token_response)
50
+ request.headers["Authorization"] = f"Bearer {self._token}"
51
+ yield request
52
+
53
+ def _has_valid_token(self) -> bool:
54
+ with self._lock:
55
+ return self._token is not None and time.monotonic() < self._token_expiry
56
+
57
+ def _build_token_request(self) -> httpx.Request:
58
+ return httpx.Request(
59
+ method="POST",
60
+ url=self._token_url,
61
+ data={
62
+ "grant_type": "client_credentials",
63
+ "client_id": self._client_id,
64
+ "client_secret": self._client_secret,
65
+ },
66
+ headers={
67
+ "Content-Type": "application/x-www-form-urlencoded",
68
+ },
69
+ )
70
+
71
+ def _parse_token_response(
72
+ self,
73
+ response: httpx.Response,
74
+ ) -> None:
75
+ if response.status_code != _OK_STATUS:
76
+ raise AuthenticationError(
77
+ f"Token acquisition failed: HTTP {response.status_code}",
78
+ status_code=response.status_code,
79
+ response_body=response.text,
80
+ request_method="POST",
81
+ request_url=self._token_url,
82
+ )
83
+
84
+ body = decode_json_object(
85
+ response,
86
+ error_cls=AuthenticationError,
87
+ context=" in token response",
88
+ )
89
+ access_token = require_str(
90
+ body,
91
+ "access_token",
92
+ error_cls=AuthenticationError,
93
+ context=" in token response",
94
+ )
95
+ expires_in = require_int(
96
+ body,
97
+ "expires_in",
98
+ error_cls=AuthenticationError,
99
+ context=" in token response",
100
+ )
101
+ with self._lock:
102
+ self._token = access_token
103
+ self._token_expiry = time.monotonic() + expires_in - _EXPIRY_SAFETY_MARGIN