vantage-sdkpy 0.1.1__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 (53) hide show
  1. vantage_sdk/__init__.py +44 -0
  2. vantage_sdk/admin/__init__.py +16 -0
  3. vantage_sdk/admin/management/__init__.py +16 -0
  4. vantage_sdk/admin/management/organizations.py +60 -0
  5. vantage_sdk/auth.py +517 -0
  6. vantage_sdk/base/__init__.py +21 -0
  7. vantage_sdk/base/crud.py +475 -0
  8. vantage_sdk/client.py +82 -0
  9. vantage_sdk/cloud/__init__.py +46 -0
  10. vantage_sdk/cloud/cloud_account_crud.py +433 -0
  11. vantage_sdk/cloud/cloud_account_schema.py +177 -0
  12. vantage_sdk/cloud/crud.py +174 -0
  13. vantage_sdk/cloud/lxd_utils.py +85 -0
  14. vantage_sdk/cloud/schema.py +149 -0
  15. vantage_sdk/cluster/__init__.py +24 -0
  16. vantage_sdk/cluster/application/__init__.py +15 -0
  17. vantage_sdk/cluster/application/kubeflow.py +121 -0
  18. vantage_sdk/cluster/application/service_workflow.py +280 -0
  19. vantage_sdk/cluster/crud.py +1064 -0
  20. vantage_sdk/cluster/schema.py +92 -0
  21. vantage_sdk/config.py +100 -0
  22. vantage_sdk/constants.py +56 -0
  23. vantage_sdk/exceptions.py +74 -0
  24. vantage_sdk/gql_client.py +709 -0
  25. vantage_sdk/job/__init__.py +27 -0
  26. vantage_sdk/job/crud.py +61 -0
  27. vantage_sdk/job/schema.py +84 -0
  28. vantage_sdk/jupyterhub_client.py +211 -0
  29. vantage_sdk/jupyterhub_sdk.py +208 -0
  30. vantage_sdk/license/__init__.py +44 -0
  31. vantage_sdk/license/crud.py +81 -0
  32. vantage_sdk/license/schema.py +112 -0
  33. vantage_sdk/schemas.py +113 -0
  34. vantage_sdk/support_ticket/__init__.py +23 -0
  35. vantage_sdk/support_ticket/crud.py +745 -0
  36. vantage_sdk/support_ticket/schema.py +113 -0
  37. vantage_sdk/team/__init__.py +16 -0
  38. vantage_sdk/team/crud.py +341 -0
  39. vantage_sdk/vantage_rest_api_client.py +220 -0
  40. vantage_sdk/workbench/__init__.py +27 -0
  41. vantage_sdk/workbench/_vdeployer.py +110 -0
  42. vantage_sdk/workbench/compute_pool.py +97 -0
  43. vantage_sdk/workbench/inference_endpoint.py +196 -0
  44. vantage_sdk/workbench/inference_preset.py +86 -0
  45. vantage_sdk/workbench/model_registry.py +185 -0
  46. vantage_sdk/workbench/namespace.py +72 -0
  47. vantage_sdk/workbench/secret.py +110 -0
  48. vantage_sdk/workbench/slurm.py +300 -0
  49. vantage_sdk/workbench/user_service.py +137 -0
  50. vantage_sdk/workbench/workspace_preset.py +82 -0
  51. vantage_sdkpy-0.1.1.dist-info/METADATA +129 -0
  52. vantage_sdkpy-0.1.1.dist-info/RECORD +53 -0
  53. vantage_sdkpy-0.1.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,44 @@
1
+ # Copyright (C) 2025 Vantage Compute Corporation
2
+ # This program is free software: you can redistribute it and/or modify it under
3
+ # the terms of the GNU General Public License as published by the Free Software
4
+ # Foundation, version 3.
5
+ #
6
+ # This program is distributed in the hope that it will be useful, but WITHOUT
7
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
8
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
9
+ #
10
+ # You should have received a copy of the GNU General Public License along with
11
+ # this program. If not, see <https://www.gnu.org/licenses/>.
12
+ """Standalone Vantage SDK public exports."""
13
+
14
+ # Import base classes
15
+ from .base import BaseCRUDSDK, BaseGraphQLResourceSDK
16
+ from .cloud.cloud_account_crud import cloud_account_sdk
17
+
18
+ from .cluster.crud import cluster_sdk
19
+ from .job import JobScript, JobScriptFile, JobSubmission, JobTemplate
20
+ from .job import job_script_sdk, job_submission_sdk, job_template_sdk
21
+ from .schemas import CliContext, SDKContext
22
+ from .support_ticket.crud import support_ticket_sdk
23
+ from .team.crud import team_sdk
24
+ from .vantage_rest_api_client import VantageRestApiClient, create_vantage_rest_client
25
+
26
+ __all__ = [
27
+ "BaseCRUDSDK",
28
+ "BaseGraphQLResourceSDK",
29
+ "SDKContext",
30
+ "CliContext",
31
+ "cluster_sdk",
32
+ "cloud_account_sdk",
33
+ "team_sdk",
34
+ "support_ticket_sdk",
35
+ "job_script_sdk",
36
+ "job_template_sdk",
37
+ "job_submission_sdk",
38
+ "JobScript",
39
+ "JobScriptFile",
40
+ "JobSubmission",
41
+ "JobTemplate",
42
+ "VantageRestApiClient",
43
+ "create_vantage_rest_client",
44
+ ]
@@ -0,0 +1,16 @@
1
+ # Copyright (C) 2025 Vantage Compute Corporation
2
+ # This program is free software: you can redistribute it and/or modify it under
3
+ # the terms of the GNU General Public License as published by the Free Software
4
+ # Foundation, version 3.
5
+ #
6
+ # This program is distributed in the hope that it will be useful, but WITHOUT
7
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
8
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
9
+ #
10
+ # You should have received a copy of the GNU General Public License along with
11
+ # this program. If not, see <https://www.gnu.org/licenses/>.
12
+ """Admin SDK package for administrative operations."""
13
+
14
+ from .management import get_extra_attributes
15
+
16
+ __all__ = ["get_extra_attributes"]
@@ -0,0 +1,16 @@
1
+ # Copyright (C) 2025 Vantage Compute Corporation
2
+ # This program is free software: you can redistribute it and/or modify it under
3
+ # the terms of the GNU General Public License as published by the Free Software
4
+ # Foundation, version 3.
5
+ #
6
+ # This program is distributed in the hope that it will be useful, but WITHOUT
7
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
8
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
9
+ #
10
+ # You should have received a copy of the GNU General Public License along with
11
+ # this program. If not, see <https://www.gnu.org/licenses/>.
12
+ """Management SDK module for administrative operations."""
13
+
14
+ from .organizations import get_extra_attributes
15
+
16
+ __all__ = ["get_extra_attributes"]
@@ -0,0 +1,60 @@
1
+ # Copyright (C) 2025 Vantage Compute Corporation
2
+ # This program is free software: you can redistribute it and/or modify it under
3
+ # the terms of the GNU General Public License as published by the Free Software
4
+ # Foundation, version 3.
5
+ #
6
+ # This program is distributed in the hope that it will be useful, but WITHOUT
7
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
8
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
9
+ #
10
+ # You should have received a copy of the GNU General Public License along with
11
+ # this program. If not, see <https://www.gnu.org/licenses/>.
12
+ """Organizations management SDK functions.
13
+
14
+ This module provides SDK functions for organization-level administrative operations
15
+ such as managing extra attributes and other organization configurations.
16
+ """
17
+
18
+ import logging
19
+ from typing import Any, Dict, Optional
20
+
21
+ import typer
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ async def get_extra_attributes(ctx: typer.Context) -> Optional[Dict[Any, Any]]:
27
+ """Get organization extra attributes.
28
+
29
+ This function retrieves the list of extra attributes configured for the organization.
30
+ Extra attributes are custom fields that can be added to various entities.
31
+
32
+ The REST client is automatically initialized and attached to ctx.obj.rest_client
33
+ by the @attach_vantage_rest_client decorator.
34
+
35
+ Args:
36
+ ctx: Typer context with rest_client, settings, and persona already attached
37
+
38
+ Returns:
39
+ Dict of extra attribute data containing the organization's extra attributes,
40
+ or None if the request fails
41
+
42
+ Raises:
43
+ httpx.HTTPStatusError: If the API request fails
44
+ Exception: For other request failures
45
+
46
+ Example:
47
+ >>> import typer
48
+ >>> ctx = typer.Context(...) # Context with settings and persona
49
+ >>> attributes = await get_extra_attributes(ctx)
50
+ >>> print(attributes)
51
+ """
52
+ path = "/admin/management/organizations/extra-attributes"
53
+ try:
54
+ # The VantageRestApiClient.get() method returns the JSON data directly
55
+ # (not a response object), so we can return it as-is
56
+ data = await ctx.obj.rest_client.get(path)
57
+ return data
58
+ except Exception as e:
59
+ logger.warning("Failed to retrieve extra attributes: %s", e)
60
+ return None
vantage_sdk/auth.py ADDED
@@ -0,0 +1,517 @@
1
+ # Copyright (C) 2025 Vantage Compute Corporation
2
+ # This program is free software: you can redistribute it and/or modify it under
3
+ # the terms of the GNU General Public License as published by the Free Software
4
+ # Foundation, version 3.
5
+ #
6
+ # This program is distributed in the hope that it will be useful, but WITHOUT
7
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
8
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
9
+ #
10
+ # You should have received a copy of the GNU General Public License along with
11
+ # this program. If not, see <https://www.gnu.org/licenses/>.
12
+ """Authentication and authorization functionality for the Vantage CLI."""
13
+
14
+ import asyncio
15
+ import base64
16
+ import datetime
17
+ import hashlib
18
+ import logging
19
+ import secrets
20
+ from textwrap import dedent
21
+ from typing import Any, Callable
22
+
23
+ import httpx
24
+ import snick
25
+ from jose import jwt
26
+ from jose.exceptions import ExpiredSignatureError
27
+ from pydantic import ValidationError
28
+
29
+ from vantage_sdk.client import make_oauth_request
30
+ from vantage_sdk.config import Settings
31
+ from vantage_sdk.constants import (
32
+ OIDC_DEVICE_PATH,
33
+ OIDC_SCOPES,
34
+ OIDC_TOKEN_PATH,
35
+ TOKEN_REFRESH_THRESHOLD_SECONDS,
36
+ )
37
+ from vantage_sdk.exceptions import Abort
38
+ from vantage_sdk.schemas import CliContext, DeviceCodeData, IdentityData, Persona, TokenSet
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ def generate_pkce_pair() -> tuple[str, str]:
44
+ """Generate a PKCE code verifier and code challenge pair.
45
+
46
+ Returns:
47
+ Tuple of (code_verifier, code_challenge)
48
+ """
49
+ # Generate a random code verifier (43-128 characters)
50
+ code_verifier = secrets.token_urlsafe(64)
51
+
52
+ # Create code challenge using SHA256 and base64url encoding
53
+ code_challenge_bytes = hashlib.sha256(code_verifier.encode("ascii")).digest()
54
+ code_challenge = base64.urlsafe_b64encode(code_challenge_bytes).rstrip(b"=").decode("ascii")
55
+
56
+ return code_verifier, code_challenge
57
+
58
+
59
+ def extract_persona(token_set: TokenSet, settings: Settings | None = None) -> Persona:
60
+ """Extract a persona from an explicitly provided token set."""
61
+ token_set = refresh_token_if_needed(token_set, settings=settings)
62
+
63
+ # Now validate and extract identity from the (potentially refreshed) token
64
+ identity_data = validate_token_and_extract_identity(token_set)
65
+
66
+ logger.debug(f"Persona created with identity_data: {identity_data}")
67
+
68
+ return Persona(
69
+ token_set=token_set,
70
+ identity_data=identity_data,
71
+ )
72
+
73
+
74
+ def validate_token_and_extract_identity(token_set: TokenSet) -> IdentityData:
75
+ """Validate access token and extract user identity information."""
76
+ logger.debug("Validating access token")
77
+
78
+ token_file_is_empty = not token_set.access_token
79
+ if token_file_is_empty:
80
+ logger.debug("Access token file exists but it is empty")
81
+ raise Abort(
82
+ """
83
+ Access token file exists but it is empty.
84
+
85
+ Please try logging in again.
86
+ """,
87
+ subject="Empty access token file",
88
+ log_message="Empty access token file",
89
+ )
90
+
91
+ with Abort.handle_errors(
92
+ """
93
+ There was an unknown error while validating the access token.
94
+
95
+ Please try logging in again.
96
+ """,
97
+ ignore_exc_class=ExpiredSignatureError, # Will be handled in calling context
98
+ raise_kwargs={
99
+ "subject": "Invalid access token",
100
+ "log_message": "Unknown error while validating access access token",
101
+ },
102
+ ):
103
+ token_data = jwt.decode(
104
+ token_set.access_token,
105
+ "", # Empty key is acceptable when verify_signature is False
106
+ options={
107
+ "verify_signature": False,
108
+ "verify_aud": False,
109
+ "verify_exp": True,
110
+ },
111
+ )
112
+
113
+ logger.debug("Extracting identity data from the access token")
114
+ with Abort.handle_errors(
115
+ """
116
+ There was an error extracting the user's identity from the access token.
117
+
118
+ Please try logging in again.
119
+ """,
120
+ handle_exc_class=ValidationError,
121
+ raise_kwargs={
122
+ "subject": "Missing user data",
123
+ "log_message": "Token data could not be extracted to identity",
124
+ },
125
+ ):
126
+ if "organization" not in token_data or not token_data["organization"]:
127
+ raise Abort(
128
+ """
129
+ The access token is missing organization information.
130
+
131
+ Please ensure your user account is associated with an organization
132
+ and try logging in again.
133
+ """,
134
+ subject="Missing organization info",
135
+ log_message="Access token missing organization information",
136
+ )
137
+
138
+ # Extract org_id from organization structure
139
+ # Organization is typically: {"org-uuid": {"id": "org-uuid", ...}}
140
+ organization = token_data.get("organization", {})
141
+ logger.debug(f"Organization data extracted from token: {organization}")
142
+ org_key = next(iter(organization), None)
143
+ logger.debug(f"Organization key identified: {org_key}")
144
+ org_id = organization.get(org_key, {}).get("id", "") if org_key else ""
145
+ logger.debug(f"Extracted org_id: {org_id}")
146
+
147
+ email = token_data.get("email") or ""
148
+ # Extract username from email (part before @) or use preferred_username claim
149
+ username = token_data.get("preferred_username") or email.split("@")[0] if email else ""
150
+ identity = IdentityData(
151
+ email=email,
152
+ client_id=token_data.get("azp") or "unknown",
153
+ org_id=org_id,
154
+ org_name=org_key or "",
155
+ username=username,
156
+ )
157
+ logger.debug(f"Extracted identity data: {identity}")
158
+
159
+ return identity
160
+
161
+
162
+ def is_token_expired(token: str, buffer_seconds: int = TOKEN_REFRESH_THRESHOLD_SECONDS) -> bool:
163
+ """Check if a JWT token is expired or will expire within buffer_seconds.
164
+
165
+ Args:
166
+ token: JWT access token
167
+ buffer_seconds: Number of seconds before actual expiry to consider token expired.
168
+ Defaults to TOKEN_REFRESH_THRESHOLD_SECONDS (300s) for proactive refresh.
169
+
170
+ Returns:
171
+ True if token is expired or will expire soon, False otherwise
172
+ """
173
+ try:
174
+ # Decode token without verification to get expiration
175
+ token_data = jwt.decode(
176
+ token,
177
+ "", # Empty key is acceptable when verify_signature is False
178
+ options={
179
+ "verify_signature": False,
180
+ "verify_aud": False,
181
+ "verify_exp": False, # Don't verify expiration here, we want to check manually
182
+ },
183
+ )
184
+
185
+ if "exp" not in token_data:
186
+ logger.debug("Token does not contain expiration claim")
187
+ return True # Consider token expired if no expiration claim
188
+
189
+ exp_timestamp = token_data["exp"]
190
+ exp_datetime = datetime.datetime.fromtimestamp(exp_timestamp)
191
+ now_with_buffer = datetime.datetime.now() + datetime.timedelta(seconds=buffer_seconds)
192
+
193
+ is_expired = exp_datetime <= now_with_buffer
194
+
195
+ if is_expired:
196
+ logger.debug(
197
+ f"Token expired or will expire soon. Expires at: {exp_datetime}, Current time + buffer: {now_with_buffer}"
198
+ )
199
+ else:
200
+ logger.debug(
201
+ f"Token is valid. Expires at: {exp_datetime}, Current time + buffer: {now_with_buffer}"
202
+ )
203
+
204
+ return is_expired
205
+
206
+ except Exception as e:
207
+ logger.debug(f"Error checking token expiration: {e}")
208
+ return True # Consider token expired if we can't parse it
209
+
210
+
211
+ def refresh_token_if_needed(token_set: TokenSet, settings: Settings | None = None) -> TokenSet:
212
+ """Check if the access token is expired and refresh it if needed.
213
+
214
+ Args:
215
+ token_set: Current token set
216
+ settings: Explicit settings used for refresh requests
217
+
218
+ Returns:
219
+ Updated token set with refreshed tokens if refresh was needed
220
+ """
221
+ if not token_set.access_token:
222
+ logger.debug("No access token available")
223
+ return token_set
224
+
225
+ if not is_token_expired(token_set.access_token):
226
+ logger.debug("Access token is still valid")
227
+ return token_set
228
+
229
+ logger.debug("Access token is expired, attempting refresh")
230
+
231
+ if not token_set.refresh_token:
232
+ logger.debug("No refresh token available")
233
+ raise Abort(
234
+ "The access token is expired and no refresh token is available. Please log in again.",
235
+ subject="Token expired",
236
+ log_message="Token expired and no refresh token available",
237
+ )
238
+
239
+ try:
240
+ if settings is None:
241
+ raise Abort(
242
+ "The access token is expired and SDK settings were not provided for refresh.",
243
+ subject="Missing SDK settings",
244
+ log_message="Token refresh requested without explicit settings",
245
+ )
246
+
247
+ # Attempt to refresh the token
248
+ refresh_success = refresh_access_token_standalone(token_set, settings)
249
+
250
+ if refresh_success:
251
+ logger.debug("Successfully refreshed access token")
252
+ return token_set
253
+ else:
254
+ logger.warning("Token refresh failed - check error logs above for details")
255
+ raise Exception("Token refresh failed")
256
+
257
+ except Exception as e:
258
+ logger.debug(f"Token refresh error: {e}")
259
+ raise Abort(
260
+ dedent(
261
+ """\
262
+ Your authentication session has expired.
263
+
264
+ Please log in again by running:
265
+
266
+ vantage login
267
+ """
268
+ ),
269
+ subject="Authentication Required",
270
+ log_message=f"Token refresh failed: {e}",
271
+ )
272
+
273
+
274
+ def init_persona(
275
+ *,
276
+ access_token: str,
277
+ refresh_token: str | None = None,
278
+ settings: Settings | None = None,
279
+ ) -> Persona:
280
+ """Initialize a persona from explicit token values without filesystem access."""
281
+ token_set = TokenSet(access_token=access_token, refresh_token=refresh_token)
282
+
283
+ try:
284
+ identity_data = validate_token_and_extract_identity(token_set)
285
+ except ExpiredSignatureError:
286
+ Abort.require_condition(
287
+ token_set.refresh_token is not None,
288
+ "The auth token is expired. Please retrieve a new and log in again.",
289
+ raise_kwargs={
290
+ "subject": "Expired access token",
291
+ },
292
+ )
293
+
294
+ logger.debug("The access token is expired. Attempting to refresh token")
295
+ resolved_settings = settings or Settings()
296
+ refresh_success = refresh_access_token_standalone(token_set, resolved_settings)
297
+ if not refresh_success:
298
+ raise Exception("Failed to refresh access token")
299
+ identity_data = validate_token_and_extract_identity(token_set)
300
+
301
+ logger.debug(f"Persona created with identity_data: {identity_data}")
302
+
303
+ return Persona(
304
+ token_set=token_set,
305
+ identity_data=identity_data,
306
+ )
307
+
308
+
309
+ def refresh_access_token_standalone(token_set: TokenSet, settings: "Settings") -> bool:
310
+ """Attempt to fetch a new access token given a refresh token.
311
+
312
+ Returns True if refresh was successful, False otherwise.
313
+ Sets the access token in-place.
314
+ """
315
+ if not token_set.refresh_token:
316
+ return False
317
+
318
+ url = f"{settings.get_auth_url()}{OIDC_TOKEN_PATH}"
319
+ logger.debug(f"Requesting refreshed access token from {url}")
320
+
321
+ try:
322
+ with httpx.Client() as client:
323
+ response = client.post(
324
+ url,
325
+ data={
326
+ "client_id": settings.oidc_client_id,
327
+ "grant_type": "refresh_token",
328
+ "refresh_token": token_set.refresh_token,
329
+ },
330
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
331
+ timeout=30.0,
332
+ )
333
+ response.raise_for_status()
334
+
335
+ token_data = response.json()
336
+ token_set.access_token = token_data["access_token"]
337
+
338
+ # Update refresh token if provided
339
+ if "refresh_token" in token_data:
340
+ token_set.refresh_token = token_data["refresh_token"]
341
+
342
+ logger.debug("Successfully refreshed access token")
343
+ return True
344
+
345
+ except httpx.HTTPStatusError as e:
346
+ logger.debug(
347
+ f"Token refresh failed with status {e.response.status_code}: {e.response.text}"
348
+ )
349
+ return False
350
+ except httpx.TimeoutException as e:
351
+ logger.debug(f"Token refresh timed out: {e}")
352
+ return False
353
+ except httpx.RequestError as e:
354
+ logger.debug(f"Token refresh request error: {e}")
355
+ return False
356
+ except KeyError as e:
357
+ logger.debug(f"Token refresh response missing required field: {e}")
358
+ return False
359
+ except Exception as e:
360
+ logger.debug(f"Unexpected error during token refresh: {type(e).__name__}: {e}")
361
+ return False
362
+
363
+
364
+ async def refresh_access_token(ctx: CliContext, token_set: TokenSet):
365
+ """Attempt to fetch a new access token given a refresh token in a token_set.
366
+
367
+ Sets the access token in-place.
368
+
369
+ If refresh fails, notify the user that they need to log in again.
370
+ """
371
+ if ctx.client is None:
372
+ raise RuntimeError("HTTP client not initialized")
373
+ if ctx.settings is None:
374
+ raise RuntimeError("Settings not initialized")
375
+
376
+ url = "/realms/vantage/protocol/openid-connect/token"
377
+ logger.debug(f"Requesting refreshed access token from {url}")
378
+
379
+ refreshed_token_set: TokenSet = await make_oauth_request(
380
+ ctx.client,
381
+ url,
382
+ data={
383
+ "client_id": ctx.settings.oidc_client_id,
384
+ "grant_type": "refresh_token",
385
+ "refresh_token": token_set.refresh_token,
386
+ },
387
+ response_model_cls=TokenSet,
388
+ abort_message="The auth token could not be refreshed. Please try logging in again.",
389
+ abort_subject="EXPIRED ACCESS TOKEN",
390
+ )
391
+
392
+ token_set.access_token = refreshed_token_set.access_token
393
+
394
+
395
+ async def fetch_auth_tokens(
396
+ ctx: CliContext, status_callback: Callable[[str], None] | None = None
397
+ ) -> TokenSet:
398
+ """Fetch an access token (and possibly a refresh token) from Auth0.
399
+
400
+ Prints out a URL for the user to use to authenticate and polls the token endpoint to fetch it
401
+ when the browser-based process finishes.
402
+ """
403
+ if ctx.client is None:
404
+ raise RuntimeError("HTTP client not initialized")
405
+ if ctx.settings is None:
406
+ raise RuntimeError("Settings not initialized")
407
+
408
+ # Use console from context - it should always be available
409
+ console = ctx.console
410
+
411
+ # Generate PKCE code verifier and challenge
412
+ code_verifier, code_challenge = generate_pkce_pair()
413
+
414
+ device_code_data: DeviceCodeData = await make_oauth_request(
415
+ ctx.client,
416
+ OIDC_DEVICE_PATH,
417
+ data={
418
+ "client_id": ctx.settings.oidc_client_id,
419
+ "code_challenge": code_challenge,
420
+ "code_challenge_method": "S256",
421
+ "scope": OIDC_SCOPES,
422
+ },
423
+ response_model_cls=DeviceCodeData,
424
+ abort_message=(
425
+ """
426
+ There was a problem retrieving a device verification code from
427
+ the auth provider
428
+ """
429
+ ),
430
+ abort_subject="COULD NOT RETRIEVE TOKEN",
431
+ )
432
+
433
+ max_poll_time = 5 * 60 # 5 minutes
434
+ login_message = dedent(
435
+ f"""
436
+ To complete login, please open the following link in a browser:
437
+
438
+ {device_code_data.verification_uri_complete}
439
+
440
+ Waiting up to {max_poll_time / 60} minutes for you to complete the process...
441
+ """
442
+ ).strip()
443
+
444
+ if status_callback is not None:
445
+ status_callback(login_message)
446
+ else:
447
+ logger.info(login_message)
448
+
449
+ # Calculate timeout and start time
450
+ start_time = datetime.datetime.now()
451
+ timeout_seconds = ctx.settings.oidc_max_poll_time # This is already in seconds (int)
452
+ attempt = 0
453
+
454
+ while True:
455
+ attempt += 1
456
+ elapsed = (datetime.datetime.now() - start_time).total_seconds()
457
+
458
+ if elapsed >= timeout_seconds:
459
+ break
460
+
461
+ response_data: dict[str, Any] = {}
462
+ try:
463
+ token_data = await make_oauth_request(
464
+ ctx.client,
465
+ OIDC_TOKEN_PATH,
466
+ data={
467
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
468
+ "device_code": device_code_data.device_code,
469
+ "client_id": ctx.settings.oidc_client_id,
470
+ "code_verifier": code_verifier,
471
+ },
472
+ response_model_cls=TokenSet,
473
+ abort_message="IGNORE",
474
+ abort_subject="IGNORE",
475
+ )
476
+ return token_data
477
+ except Exception:
478
+ response = await ctx.client.post(
479
+ f"{ctx.settings.get_auth_url()}{OIDC_TOKEN_PATH}",
480
+ data={
481
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
482
+ "device_code": device_code_data.device_code,
483
+ "client_id": ctx.settings.oidc_client_id,
484
+ "code_verifier": code_verifier,
485
+ },
486
+ )
487
+ response_data = response.json()
488
+
489
+ if "error" in response_data:
490
+ if response_data["error"] == "authorization_pending":
491
+ logger.debug(f"Token fetch attempt #{attempt} failed")
492
+ logger.debug(f"Will try again in {device_code_data.interval} seconds")
493
+ await asyncio.sleep(device_code_data.interval)
494
+ elif response_data["error"] == "slow_down":
495
+ logger.debug(f"Server requested slow down on attempt #{attempt}")
496
+ logger.debug(f"Will try again in {device_code_data.interval * 2} seconds")
497
+ await asyncio.sleep(device_code_data.interval * 2)
498
+ else:
499
+ raise Abort(
500
+ snick.unwrap(
501
+ """
502
+ There was a problem retrieving a device verification code
503
+ from the auth provider:
504
+ Unexpected failure retrieving access token.
505
+ """
506
+ ),
507
+ subject="Unexpected error",
508
+ log_message=f"Unexpected error response: {response_data}",
509
+ )
510
+ else:
511
+ return TokenSet(**response_data)
512
+
513
+ raise Abort(
514
+ "Login process was not completed in time. Please try again.",
515
+ subject="Timed out",
516
+ log_message="Timed out while waiting for user to complete login",
517
+ )
@@ -0,0 +1,21 @@
1
+ # Copyright (C) 2025 Vantage Compute Corporation
2
+ # This program is free software: you can redistribute it and/or modify it under
3
+ # the terms of the GNU General Public License as published by the Free Software
4
+ # Foundation, version 3.
5
+ #
6
+ # This program is distributed in the hope that it will be useful, but WITHOUT
7
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
8
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
9
+ #
10
+ # You should have received a copy of the GNU General Public License along with
11
+ # this program. If not, see <https://www.gnu.org/licenses/>.
12
+ """Base SDK classes for CRUD operations."""
13
+
14
+ from .crud import BaseCRUDSDK, BaseGraphQLResourceSDK, BaseLocalResourceSDK, BaseRestApiResourceSDK
15
+
16
+ __all__ = [
17
+ "BaseCRUDSDK",
18
+ "BaseLocalResourceSDK",
19
+ "BaseGraphQLResourceSDK",
20
+ "BaseRestApiResourceSDK",
21
+ ]