icmd-python 1.1.4__py3-none-any.whl → 1.2.3__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.

Potentially problematic release.


This version of icmd-python might be problematic. Click here for more details.

icmd/__init__.py CHANGED
@@ -1,3 +1,5 @@
1
1
  """Python client app for ICMD® API."""
2
2
 
3
- from .core import Session
3
+ from .core import ICMD
4
+
5
+ __all__ = ["ICMD"]
icmd/core/__init__.py CHANGED
@@ -1,4 +1,12 @@
1
- """Core module for icmd package."""
1
+ """Core ICMD client modules."""
2
2
 
3
- from .base_client import BaseClient
4
- from .session import Session
3
+ from .client import ICMD, ICMDAuthenticationError, ICMDConnectionError, ICMDValidationError
4
+ from .credentials import CredentialManager
5
+
6
+ __all__ = [
7
+ "ICMD",
8
+ "CredentialManager",
9
+ "ICMDAuthenticationError",
10
+ "ICMDConnectionError",
11
+ "ICMDValidationError",
12
+ ]
icmd/core/client.py ADDED
@@ -0,0 +1,482 @@
1
+ """ICMD client - clean, fast, intuitive."""
2
+
3
+ import contextlib
4
+ import webbrowser
5
+ from datetime import UTC, datetime, timedelta
6
+ from getpass import getpass
7
+ from http import HTTPStatus
8
+ from typing import Any
9
+
10
+ import requests
11
+ from requests.adapters import HTTPAdapter
12
+
13
+ from .credentials import CredentialManager
14
+
15
+
16
+ class ICMDAuthenticationError(Exception):
17
+ """Raised when authentication fails."""
18
+
19
+
20
+ class ICMDConnectionError(Exception):
21
+ """Raised when connection to ICMD fails."""
22
+
23
+
24
+ class ICMDValidationError(Exception):
25
+ """Raised when input validation fails."""
26
+
27
+
28
+ class ICMD:
29
+ """ICMD client - supports multiple domains per process."""
30
+
31
+ # Configuration constants
32
+ _DEFAULT_POOL_CONNECTIONS = 20
33
+ _DEFAULT_POOL_MAXSIZE = 20
34
+ _DEFAULT_MAX_RETRIES = 3
35
+ _DEFAULT_TIMEOUT = 30
36
+ _TOKEN_EXPIRE_BUFFER_MS = 5000
37
+ _HTTP_NOT_FOUND = 404
38
+
39
+ def __init__(self, domain: str, jupyterlite: bool = False, skip_auth: bool = False):
40
+ """Initialize ICMD client with required domain parameter.
41
+
42
+ Args:
43
+ domain: REQUIRED domain (e.g., 'icmd.questek.com', 'cust.icmd.questek.com')
44
+ jupyterlite: Skip auto-authentication for JupyterLite
45
+ skip_auth: Skip auto-authentication (useful for testing or non-interactive
46
+ environments)
47
+
48
+ Raises
49
+ ------
50
+ ValueError
51
+ If domain is not provided or invalid
52
+ """
53
+ if not domain:
54
+ raise ValueError("Domain is required: ICMD('icmd.questek.com')")
55
+
56
+ self.domain = self._validate_domain(domain)
57
+ self._session = requests.Session()
58
+ self._session.headers.update({"Content-Type": "application/json"})
59
+
60
+ # Connection pooling configuration
61
+ adapter = HTTPAdapter(
62
+ pool_connections=self._DEFAULT_POOL_CONNECTIONS,
63
+ pool_maxsize=self._DEFAULT_POOL_MAXSIZE,
64
+ max_retries=self._DEFAULT_MAX_RETRIES,
65
+ pool_block=False,
66
+ )
67
+ self._session.mount("http://", adapter)
68
+ self._session.mount("https://", adapter)
69
+
70
+ # Set reasonable timeouts by default
71
+ self._default_timeout = self._DEFAULT_TIMEOUT
72
+
73
+ self._credentials = CredentialManager()
74
+ self.auth_method = self._auto_detect_auth_method()
75
+ self.jupyterlite = jupyterlite
76
+
77
+ self._refresh_token = ""
78
+ self._id_token = ""
79
+ self._id_token_expiration = datetime.now(UTC)
80
+ self._token_expire_buffer = timedelta(milliseconds=self._TOKEN_EXPIRE_BUFFER_MS)
81
+
82
+ # Initialize default timeout (needed for tests that bypass __init__)
83
+ if not hasattr(self, "_default_timeout"):
84
+ self._default_timeout = self._DEFAULT_TIMEOUT
85
+
86
+ self._load_cached_credentials()
87
+
88
+ if not (jupyterlite or skip_auth):
89
+ self._ensure_authenticated()
90
+
91
+ def _validate_domain(self, domain: str) -> str:
92
+ """Validate domain format and return normalized domain.
93
+
94
+ Args
95
+ ----
96
+ domain: Domain to validate
97
+
98
+ Returns
99
+ -------
100
+ Normalized domain string
101
+
102
+ Raises
103
+ ------
104
+ ValueError: If domain format is invalid
105
+ """
106
+ if not domain or not isinstance(domain, str):
107
+ raise ValueError(f"Invalid domain: {domain}. Must be a non-empty string.")
108
+
109
+ normalized = self._normalize_domain(domain)
110
+
111
+ # Accept: icmd.questek.com or *.icmd.questek.com
112
+ if ".questek.com" in normalized:
113
+ return normalized
114
+
115
+ raise ValueError(f"Invalid domain: {domain}. Must be a 'questek.com' subdomain")
116
+
117
+ def _normalize_domain(self, domain: str) -> str:
118
+ """Normalize domain to https:// format."""
119
+ if not isinstance(domain, str):
120
+ raise ICMDValidationError("Domain must be a string")
121
+
122
+ domain = domain.strip()
123
+ if not domain:
124
+ raise ICMDValidationError("Domain cannot be empty")
125
+
126
+ if not domain.startswith("http"):
127
+ domain = f"https://{domain}"
128
+ return domain
129
+
130
+ def _get_server_config(self) -> dict:
131
+ """Fetch server configuration from /api/v1/public/config/ endpoint."""
132
+ try:
133
+ config_url = f"{self.api_root}/public/config/"
134
+ response = self._session.get(config_url, timeout=5)
135
+
136
+ if response.status_code == HTTPStatus.OK:
137
+ return response.json()
138
+ except (requests.RequestException, ValueError, OSError, Exception):
139
+ # Network issues, JSON parsing errors, timeouts, or any other errors
140
+ pass
141
+
142
+ return {}
143
+
144
+ def _enhance_auth_error(self, error_response: requests.Response, auth_method: str) -> str:
145
+ """Enhance authentication error messages with Cognito-specific context."""
146
+ base_error = f"{auth_method} authentication failed"
147
+ enhanced_error = base_error
148
+
149
+ try:
150
+ error_data = error_response.json()
151
+ if not isinstance(error_data, dict):
152
+ return enhanced_error
153
+
154
+ # Extract error message
155
+ error_msg = error_data.get("error") or error_data.get("message", "")
156
+
157
+ # Map error types to enhanced messages
158
+ error_enhancements = {
159
+ "InvalidParameterException": "Invalid credentials or user not found",
160
+ "UserNotFoundException": "User account not found",
161
+ "CodeMismatchException": "Invalid authentication code",
162
+ "ExpiredCodeException": "Authentication code has expired, please try again",
163
+ "TooManyRequestsException": "Too many attempts, please wait before trying again",
164
+ }
165
+
166
+ # Check for specific error types
167
+ for error_type, enhancement in error_enhancements.items():
168
+ if error_type in error_msg:
169
+ enhanced_error = f"{base_error}: {enhancement}"
170
+ break
171
+ else:
172
+ # Handle NotAuthorizedException with method-specific messages
173
+ if "NotAuthorizedException" in error_msg:
174
+ if auth_method == "PASSWORD":
175
+ enhanced_error = f"{base_error}: Incorrect username or password"
176
+ else:
177
+ enhanced_error = f"{base_error}: Access denied by identity provider"
178
+ # Handle networking errors
179
+ elif "NetworkingError" in error_msg or "timeout" in error_msg.lower():
180
+ enhanced_error = (
181
+ f"{base_error}: Network connectivity issue with identity provider"
182
+ )
183
+ # Generic error message if available
184
+ elif error_msg:
185
+ enhanced_error = f"{base_error}: {error_msg}"
186
+
187
+ except (ValueError, KeyError):
188
+ pass
189
+
190
+ return enhanced_error
191
+
192
+ def _auto_detect_auth_method(self) -> str:
193
+ """Enhanced auth method detection using server config discovery."""
194
+ if cached := self._credentials.get_auth_method(self.domain):
195
+ return cached
196
+
197
+ # Server config discovery for Cognito IdP detection
198
+ config = self._get_server_config()
199
+ if idps := config.get("identityProviders", []):
200
+ # Prefer external IdP (Azure AD, etc.) over native Cognito password
201
+ external_idps = [idp for idp in idps if idp != "UP"]
202
+ if external_idps:
203
+ return "SAML" # External IdP available
204
+ if "UP" in idps:
205
+ return "PASSWORD" # Cognito native user/password
206
+
207
+ # Fallback: When server config is unavailable, default to PASSWORD
208
+ # NOTE: The consent endpoint exists for both UP-only and SAML-enabled instances,
209
+ # so we cannot reliably determine SAML availability without server config.
210
+ # Conservative approach: assume PASSWORD when config is unavailable.
211
+ return "PASSWORD"
212
+
213
+ @property
214
+ def api_root(self) -> str:
215
+ """Return the root of the ICMD API."""
216
+ return f"{self.domain}/api/v1"
217
+
218
+ def _load_cached_credentials(self) -> None:
219
+ """Load cached credentials from ~/.icmd.json."""
220
+ if refresh_token := self._credentials.get_refresh_token(self.domain):
221
+ self._refresh_token = refresh_token
222
+
223
+ def _save_credentials(self) -> None:
224
+ """Save credentials to ~/.icmd.json."""
225
+ self._credentials.save_domain_credentials(
226
+ self.domain, self.auth_method, self._refresh_token
227
+ )
228
+
229
+ def _is_token_expired(self) -> bool:
230
+ """Check if current token is expired."""
231
+ return self._id_token_expiration - self._token_expire_buffer <= datetime.now(UTC)
232
+
233
+ def _ensure_authenticated(self) -> None:
234
+ """Ensure we have a valid authentication token with smart refresh."""
235
+ # Check if we already have a valid token
236
+ if self._id_token and not self._is_token_expired():
237
+ return
238
+
239
+ # Try refresh token if available
240
+ if self._refresh_token:
241
+ try:
242
+ self._refresh_access_token()
243
+ return
244
+ except Exception:
245
+ # Clear invalid refresh token
246
+ self._refresh_token = ""
247
+ self._credentials.clear_refresh_token(self.domain)
248
+
249
+ # Full authentication required
250
+ self._perform_authentication()
251
+
252
+ def authenticate(self) -> None:
253
+ """Manually trigger authentication. Useful when ICMD was created with skip_auth=True."""
254
+ self._ensure_authenticated()
255
+
256
+ def _refresh_access_token(self) -> None:
257
+ """Refresh access token using refresh token."""
258
+ try:
259
+ url = f"{self.api_root}/account/auth/refresh/"
260
+ response = self._session.post(
261
+ url, json={"refreshToken": self._refresh_token}, timeout=self._default_timeout
262
+ )
263
+
264
+ if response.status_code != HTTPStatus.OK:
265
+ error_msg = "Token refresh failed"
266
+ try:
267
+ error_data = response.json()
268
+ if isinstance(error_data, dict) and "error" in error_data:
269
+ error_msg = f"Token refresh failed: {error_data['error']}"
270
+ except (ValueError, KeyError):
271
+ pass
272
+ raise ICMDAuthenticationError(error_msg)
273
+
274
+ self._handle_auth_response(response)
275
+
276
+ except requests.RequestException as e:
277
+ raise ICMDConnectionError(f"Network error during token refresh: {e}") from e
278
+
279
+ def _perform_authentication(self) -> None:
280
+ """Perform full authentication flow."""
281
+ if self.auth_method == "SAML":
282
+ self._saml_authentication()
283
+ else:
284
+ self._password_authentication()
285
+
286
+ self._save_credentials()
287
+
288
+ def _password_authentication(self) -> None:
289
+ """Authenticate with username/password."""
290
+ try:
291
+ username = input("Your ICMD® email: ").strip()
292
+ if not username:
293
+ raise ICMDValidationError("Username cannot be empty")
294
+
295
+ password = getpass("Your ICMD® password: ")
296
+ if not password:
297
+ raise ICMDValidationError("Password cannot be empty")
298
+
299
+ mfa_code = input("Your MFA Code (if enabled): ").strip()
300
+
301
+ url = f"{self.api_root}/account/auth/login/"
302
+ response = self._session.post(
303
+ url,
304
+ json={"username": username, "password": password},
305
+ timeout=self._default_timeout,
306
+ )
307
+
308
+ if response.status_code != HTTPStatus.OK:
309
+ error_msg = self._enhance_auth_error(response, "PASSWORD")
310
+ raise ICMDAuthenticationError(error_msg)
311
+
312
+ auth_data = response.json()
313
+ if auth_data.get("challenge"):
314
+ self._handle_mfa_challenge(auth_data["challenge"], username, mfa_code)
315
+ else:
316
+ self._handle_auth_response(response)
317
+
318
+ except EOFError:
319
+ raise ICMDAuthenticationError(
320
+ "Cannot authenticate in non-interactive environment. "
321
+ "Consider using ICMD(skip_auth=True) and calling authenticate() later."
322
+ ) from None
323
+ except requests.RequestException as e:
324
+ raise ICMDConnectionError(f"Network error during password authentication: {e}") from e
325
+ except KeyboardInterrupt:
326
+ raise ICMDAuthenticationError("Authentication cancelled by user") from None
327
+
328
+ def _handle_mfa_challenge(self, challenge: dict, username: str, mfa_code: str) -> None:
329
+ """Handle MFA challenge."""
330
+ try:
331
+ if not mfa_code:
332
+ mfa_code = input("Your MFA Code: ").strip()
333
+
334
+ if not mfa_code:
335
+ raise ICMDValidationError("MFA code cannot be empty")
336
+
337
+ url = f"{self.api_root}/account/auth/mfa/"
338
+ response = self._session.post(
339
+ url,
340
+ json={
341
+ "mfa_code": mfa_code,
342
+ "session": challenge["Session"],
343
+ "username": username,
344
+ "challenge_name": challenge.get("ChallengeName"),
345
+ },
346
+ timeout=self._default_timeout,
347
+ )
348
+
349
+ if response.status_code != HTTPStatus.OK:
350
+ error_msg = "MFA verification failed"
351
+ try:
352
+ error_data = response.json()
353
+ if isinstance(error_data, dict) and "error" in error_data:
354
+ error_msg = f"MFA failed: {error_data['error']}"
355
+ except (ValueError, KeyError):
356
+ pass
357
+ raise ICMDAuthenticationError(error_msg)
358
+
359
+ self._handle_auth_response(response)
360
+
361
+ except requests.RequestException as e:
362
+ raise ICMDConnectionError(f"Network error during MFA verification: {e}") from e
363
+ except KeyboardInterrupt:
364
+ raise ICMDAuthenticationError("MFA verification cancelled by user") from None
365
+
366
+ def _saml_authentication(self) -> None:
367
+ """Authenticate with SAML SSO."""
368
+ try:
369
+ print("\n\033[94mSigning into ICMD® with SAML SSO...\033[0m\n")
370
+
371
+ consent_url = f"{self.api_root}/account/auth/consent"
372
+ print(f"Opening browser: {consent_url}")
373
+ print("If it doesn't open, copy this URL to your browser:")
374
+ print(f"\n{consent_url}\n")
375
+
376
+ with contextlib.suppress(Exception):
377
+ webbrowser.open(consent_url)
378
+
379
+ auth_code = input("Please enter the authentication code: ").strip()
380
+ if not auth_code:
381
+ raise ICMDValidationError("Authentication code required")
382
+
383
+ url = f"{self.api_root}/account/auth/login/"
384
+ response = self._session.post(
385
+ url,
386
+ json={"code": auth_code, "redirect_uri": f"{self.api_root}/account/auth/exchange"},
387
+ timeout=self._default_timeout,
388
+ )
389
+
390
+ if response.status_code != HTTPStatus.OK:
391
+ error_msg = self._enhance_auth_error(response, "SAML")
392
+ raise ICMDAuthenticationError(error_msg)
393
+
394
+ self._handle_auth_response(response)
395
+ print("\n\033[92mSigned in successfully.\033[0m\n")
396
+
397
+ except requests.RequestException as e:
398
+ raise ICMDConnectionError(f"Network error during SAML authentication: {e}") from e
399
+ except KeyboardInterrupt:
400
+ raise ICMDAuthenticationError("SAML authentication cancelled by user") from None
401
+
402
+ def _handle_auth_response(self, response: requests.Response) -> None:
403
+ """Handle authentication response and extract tokens."""
404
+ auth_data = response.json()
405
+
406
+ # Update session headers
407
+ if user_context := auth_data.get("userFingerprint"):
408
+ self._session.headers["X-User-Context"] = user_context
409
+
410
+ # Extract tokens
411
+ self._id_token = auth_data.get("idToken", "")
412
+ if refresh_token := auth_data.get("refreshToken"):
413
+ self._refresh_token = refresh_token
414
+
415
+ # Set expiration
416
+ if expires_at := auth_data.get("expiresAt"):
417
+ self._id_token_expiration = datetime.fromisoformat(expires_at)
418
+
419
+ # Set bearer auth
420
+ if self._id_token:
421
+ self._session.headers["Authorization"] = f"Bearer {self._id_token}"
422
+
423
+ # Handle cookies
424
+ for cookie in response.cookies:
425
+ if cookie.name == "Secure-Fgp" and cookie.value:
426
+ self._session.cookies.set("Secure-Fgp", cookie.value)
427
+
428
+ def request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
429
+ """Make authenticated request to ICMD API with smart retry and error handling."""
430
+ try:
431
+ self._ensure_authenticated()
432
+
433
+ # Clean and validate endpoint
434
+ if not endpoint:
435
+ raise ICMDValidationError("Endpoint cannot be empty")
436
+
437
+ endpoint = endpoint.strip("/")
438
+ url = f"{self.api_root}/{endpoint}/"
439
+
440
+ # Set default timeout if not provided
441
+ if "timeout" not in kwargs:
442
+ kwargs["timeout"] = self._default_timeout
443
+
444
+ # Make request with automatic retry on auth failure
445
+ response = self._session.request(method, url, **kwargs)
446
+
447
+ # Handle token expiration with single retry
448
+ if response.status_code == HTTPStatus.UNAUTHORIZED and not kwargs.get("_retry_auth"):
449
+ # Clear token and re-authenticate
450
+ self._id_token = ""
451
+ self._session.headers.pop("Authorization", None)
452
+ self._ensure_authenticated()
453
+
454
+ # Retry once with fresh token
455
+ kwargs["_retry_auth"] = True
456
+ response = self._session.request(method, url, **kwargs)
457
+
458
+ return response
459
+
460
+ except (ICMDAuthenticationError, ICMDConnectionError, ICMDValidationError):
461
+ # Re-raise our custom exceptions
462
+ raise
463
+ except requests.RequestException as e:
464
+ raise ICMDConnectionError(f"Request failed for {method} {endpoint}: {e}") from e
465
+ except Exception as e:
466
+ raise ICMDConnectionError(f"Unexpected error during request: {e}") from e
467
+
468
+ def get(self, endpoint: str, **kwargs) -> requests.Response:
469
+ """GET request."""
470
+ return self.request("GET", endpoint, **kwargs)
471
+
472
+ def post(self, endpoint: str, data: Any = None, **kwargs) -> requests.Response:
473
+ """POST request."""
474
+ return self.request("POST", endpoint, json=data, **kwargs)
475
+
476
+ def put(self, endpoint: str, data: Any = None, **kwargs) -> requests.Response:
477
+ """PUT request."""
478
+ return self.request("PUT", endpoint, json=data, **kwargs)
479
+
480
+ def delete(self, endpoint: str, **kwargs) -> requests.Response:
481
+ """DELETE request."""
482
+ return self.request("DELETE", endpoint, **kwargs)
@@ -0,0 +1,144 @@
1
+ """Credential management for ICMD client."""
2
+
3
+ import json
4
+ import logging
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class CredentialManager:
13
+ """Handles domain-keyed credential persistence for multiple ICMD instances."""
14
+
15
+ def __init__(self, credential_file: str | None = None):
16
+ """Initialize credential manager.
17
+
18
+ Args:
19
+ credential_file: Optional custom path to credential file.
20
+ Defaults to ~/.icmd.json
21
+ """
22
+ self.credential_file = Path(credential_file or Path.home() / ".icmd.json")
23
+
24
+ def load_domain_credentials(self, domain: str) -> dict[str, Any]:
25
+ """Load cached credentials for specific domain.
26
+
27
+ Args:
28
+ domain: Domain to load credentials for
29
+
30
+ Returns
31
+ -------
32
+ Dictionary containing auth_method, refresh_token, etc.
33
+ Empty dict if file doesn't exist or domain not found.
34
+ """
35
+ all_credentials = self._load_all_credentials()
36
+ return all_credentials.get(domain, {})
37
+
38
+ def _load_all_credentials(self) -> dict[str, dict[str, Any]]:
39
+ """Load all domain-keyed credentials from file.
40
+
41
+ Returns
42
+ -------
43
+ Dictionary with domain keys and credential values.
44
+ Empty dict if file doesn't exist or can't be read.
45
+ """
46
+ if not self.credential_file.exists():
47
+ return {}
48
+
49
+ try:
50
+ with open(self.credential_file) as f:
51
+ data = json.load(f)
52
+
53
+ # Validate domain-keyed structure
54
+ if not isinstance(data, dict):
55
+ logger.warning("Credential file contains invalid data structure")
56
+ return {}
57
+
58
+ return data
59
+
60
+ except (json.JSONDecodeError, OSError) as e:
61
+ logger.warning(f"Failed to load credential file: {e}")
62
+ return {}
63
+
64
+ def save_domain_credentials(
65
+ self, domain: str, auth_method: str, refresh_token: str, **additional_data: Any
66
+ ) -> None:
67
+ """Save credentials for specific domain, preserving other domains.
68
+
69
+ Args:
70
+ domain: ICMD domain
71
+ auth_method: Authentication method (SAML/PASSWORD)
72
+ refresh_token: Refresh token for subsequent sessions
73
+ **additional_data: Additional data to store
74
+ """
75
+ # Load existing credentials
76
+ all_credentials = self._load_all_credentials()
77
+
78
+ # Update credentials for this domain
79
+ all_credentials[domain] = {
80
+ "auth_method": auth_method,
81
+ "refresh_token": refresh_token,
82
+ "saved_at": datetime.now(UTC).isoformat(),
83
+ **additional_data,
84
+ }
85
+
86
+ self._save_all_credentials(all_credentials)
87
+
88
+ def _save_all_credentials(self, all_credentials: dict[str, dict[str, Any]]) -> None:
89
+ """Save all domain-keyed credentials to file.
90
+
91
+ Args:
92
+ all_credentials: Dictionary with domain keys and credential values
93
+ """
94
+ try:
95
+ # Ensure parent directory exists
96
+ self.credential_file.parent.mkdir(parents=True, exist_ok=True)
97
+
98
+ # Save with proper permissions (readable only by user)
99
+ with open(self.credential_file, "w") as f:
100
+ json.dump(all_credentials, f, indent=2)
101
+
102
+ # Set file permissions to be readable only by user
103
+ self.credential_file.chmod(0o600)
104
+
105
+ logger.debug(f"Saved credentials to {self.credential_file}")
106
+
107
+ except OSError as e:
108
+ logger.error(f"Failed to save credentials: {e}")
109
+ # Don't raise - failing to save credentials shouldn't break the session
110
+
111
+ def clear_session_data(self) -> None:
112
+ """Clear cached session data by removing the credential file."""
113
+ try:
114
+ if self.credential_file.exists():
115
+ self.credential_file.unlink()
116
+ logger.info("Cleared cached credentials")
117
+ except OSError as e:
118
+ logger.warning(f"Failed to clear credentials: {e}")
119
+
120
+ def get_auth_method(self, domain: str) -> str | None:
121
+ """Get cached authentication method for domain."""
122
+ data = self.load_domain_credentials(domain)
123
+ return data.get("auth_method")
124
+
125
+ def get_refresh_token(self, domain: str) -> str | None:
126
+ """Get cached refresh token for domain."""
127
+ data = self.load_domain_credentials(domain)
128
+ return data.get("refresh_token")
129
+
130
+ def clear_refresh_token(self, domain: str) -> None:
131
+ """Clear only the refresh token for domain, keeping other session data."""
132
+ all_credentials = self._load_all_credentials()
133
+ if domain in all_credentials and "refresh_token" in all_credentials[domain]:
134
+ all_credentials[domain].pop("refresh_token")
135
+ self._save_all_credentials(all_credentials)
136
+ logger.debug(f"Cleared invalid refresh token for domain {domain}")
137
+
138
+ def clear_domain_credentials(self, domain: str) -> None:
139
+ """Clear all credentials for specific domain."""
140
+ all_credentials = self._load_all_credentials()
141
+ if domain in all_credentials:
142
+ del all_credentials[domain]
143
+ self._save_all_credentials(all_credentials)
144
+ logger.info(f"Cleared credentials for domain {domain}")
icmd/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "1.1.4"
1
+ __version__ = "1.2.3"
@@ -1,10 +1,11 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: icmd-python
3
- Version: 1.1.4
3
+ Version: 1.2.3
4
4
  Summary: Python client app for ICMD®
5
5
  Author: QuesTek Innovations LLC
6
- Requires-Python: >=3.10
6
+ Requires-Python: >=3.12
7
7
  Requires-Dist: requests
8
+ Requires-Dist: jupyter
8
9
  Provides-Extra: dev
9
10
  Requires-Dist: myst-nb; extra == "dev"
10
11
  Requires-Dist: pytest; extra == "dev"
@@ -0,0 +1,9 @@
1
+ icmd/__init__.py,sha256=Y3zT6WXeWkIuMsLjwXs1lTjiGSj-dzLvetVSmRu_u2w,84
2
+ icmd/version.py,sha256=C-D_WWrVkBDmQmApLcm0sWNh2CgIrwWfc8_sB5vvU-Q,22
3
+ icmd/core/__init__.py,sha256=4-zYFBsm52YklOAfbd0EF6wqAQS2bCIDeRAQFJRX9Lw,305
4
+ icmd/core/client.py,sha256=OHMZvO6bG2pF94gyaXnStV6QIYRDOuCePgPNQaTUKrQ,18858
5
+ icmd/core/credentials.py,sha256=rOE1uswqsWpKeNaFO1PMP4EVdNB0tvHzNShG8y7gVA0,5356
6
+ icmd_python-1.2.3.dist-info/METADATA,sha256=YKPWHIxGtImmUdO0zXuCwQSj7gunuhZtYb6RyRBVti8,501
7
+ icmd_python-1.2.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ icmd_python-1.2.3.dist-info/top_level.txt,sha256=olzM6Z_QEwg7RrFVOnkgO0I2Ps2m7vhSqYQZYeiawcw,5
9
+ icmd_python-1.2.3.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.6.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
icmd/clients/__init__.py DELETED
@@ -1 +0,0 @@
1
- """Client apps for ICMD API."""
@@ -1,93 +0,0 @@
1
- """Client for the CALPHAD Simulation Toolkit."""
2
-
3
- import warnings
4
- from http import HTTPStatus
5
-
6
- import pandas as pd
7
- from pandas.io.formats.style import Styler
8
-
9
- from icmd.core import BaseClient
10
- from icmd.core.job_table_format import qt_job_table_style
11
-
12
- try:
13
- import qt_calphad
14
- except ImportError:
15
- qt_calphad = None
16
-
17
-
18
- class CalphadClient(BaseClient):
19
- """Client for the CALPHAD Simulation Toolkit."""
20
-
21
- app_name = "simulation_calphad"
22
-
23
- def __init__(self):
24
- super().__init__()
25
-
26
- if qt_calphad is None:
27
- raise NotImplementedError("qt_calphad is not installed in current Python.")
28
-
29
- def submit_job(self, name: str, input_dict: dict, send_email: bool = True) -> str:
30
- """Submit a job to the queue."""
31
- data = {"name": name, "input_dict": input_dict, "send_email": send_email}
32
- self.send_request("post", "job", data)
33
-
34
- if self.response.status_code != HTTPStatus.CREATED:
35
- raise ValueError("Job submission failed. Check out client.request for more info.")
36
-
37
- data = self.response.json()
38
- return data["uuid"]
39
-
40
- def retrieve_job(self, uuid: str):
41
- """Retrieve a job from server with given id and load job as calculation object."""
42
- self.send_request("get", f"job/{uuid}/get_full_job/")
43
-
44
- if self.response.status_code != HTTPStatus.OK:
45
- raise ValueError(
46
- "Job retrieval failed. Check out client.session.request for more info."
47
- )
48
-
49
- data = self.response.json()
50
-
51
- if data.get("job_status") != "successful" or not data.get("results", {}).get("data"):
52
- warnings.warn(
53
- "This job is not completed or successful. Loading only input-dict...", stacklevel=2
54
- )
55
- calculation = qt_calphad.load_calculation(data.get("input_dict"), {}, [])
56
- else:
57
- try:
58
- calculation = qt_calphad.load_calculation(
59
- data.get("input_dict"), data.get("results", {}).get("data"), plots=[]
60
- )
61
- except Exception as e:
62
- warnings.warn(f"Failed to load full calculation: {e}", stacklevel=2)
63
- warnings.warn("Loading only input-dict...", stacklevel=2)
64
- calculation = qt_calphad.load_calculation(data.get("input_dict"), {}, [])
65
-
66
- return calculation
67
-
68
- def delete_job(self, uuid: str) -> None:
69
- """Delete a job with known id."""
70
- self.send_request("delete", f"job/{uuid}")
71
-
72
- if self.response.status_code != HTTPStatus.NO_CONTENT:
73
- raise ValueError("Job deletion failed. Make sure you have the permission to delete.")
74
-
75
- def list_my_jobs(self, jupyter=True) -> pd.DataFrame | Styler:
76
- """List all jobs belong to current user."""
77
- self.send_request("get", "job/my_jobs")
78
- data = self.response.json()
79
- df_data = pd.DataFrame(data)
80
-
81
- if jupyter:
82
- df_data.insert(0, "icmd", "")
83
- df_data["icmd"] = df_data.apply(lambda row: get_icmd_url(row), axis=1)
84
-
85
- return df_data.style.format(qt_job_table_style).hide(axis="index")
86
- return df_data
87
-
88
-
89
- def get_icmd_url(row):
90
- """Generate url endpoints for each df row."""
91
- if row["type"] in ["point", "step", "grid"]:
92
- return f'/simulation_calphad/equilibrium/{row["uuid"]}'
93
- return f'/simulation_calphad/{row["type"]}/{row["uuid"]}'
icmd/core/base_client.py DELETED
@@ -1,29 +0,0 @@
1
- """Base class for ICMD API clients."""
2
-
3
- from typing import TYPE_CHECKING
4
-
5
- if TYPE_CHECKING:
6
- import requests
7
-
8
- from .session import Session
9
-
10
-
11
- class BaseClient:
12
- """Client to facilitate user workflows with QuesTek IDE APIs."""
13
-
14
- app_name = "" # app name in Django urls, provide in subclasses
15
-
16
- def __init__(self):
17
- self.session: Session = Session()
18
- self.response: requests.Response | None = None
19
-
20
- def send_request(self, method: str, endpoint: str, data: dict | None = None, **kwargs):
21
- """Send a request to the ICMD API."""
22
- url = self._get_url(endpoint)
23
- self.response = self.session.checked_request(method, url, json=data, **kwargs)
24
-
25
- def _get_url(self, endpoint: str) -> str:
26
- """Get the full url for a request with app-specific endpoint."""
27
- _app_name = self.app_name.strip("/")
28
- _endpoint = endpoint.strip("/")
29
- return f"{_app_name}/{_endpoint}/"
@@ -1,48 +0,0 @@
1
- """Format strings in the job table."""
2
-
3
-
4
- def make_clickable(url):
5
- """Make a URL clickable."""
6
- url = url.lstrip("/")
7
- return f'<a href="https://server.questek.com/icmd/{url}">Link</a>'
8
-
9
-
10
- def beautify_job_status(status: str):
11
- """Beautify job status."""
12
- if status == "successful":
13
- return (
14
- '<span style="color:white; display:inline-block; padding:5px; '
15
- 'background-color:rgb(46, 125, 50)">Successful</span>'
16
- )
17
- if status == "failed":
18
- return (
19
- '<span style="color:white; display:inline-block; padding:5px; '
20
- 'background-color:rgb(211, 47, 47)">Failed</span>'
21
- )
22
- if status == "running":
23
- return (
24
- '<span style="color:white; display:inline-block; padding:5px; '
25
- 'background-color:rgb(237, 108, 2)">Running</span>'
26
- )
27
- if status == "submitted":
28
- return (
29
- '<span style="color:white; display:inline-block; padding:5px; '
30
- 'background-color:rgb(55, 111, 208)">Submitted</span>'
31
- )
32
- return status
33
-
34
-
35
- def beautify_datetime(value: str) -> str:
36
- """Beautify datetime string."""
37
- if isinstance(value, str):
38
- return value.split(".")[0].replace("T", " ")
39
- return str(value)
40
-
41
-
42
- qt_job_table_style = {
43
- "icmd": make_clickable,
44
- "job_status": beautify_job_status,
45
- "job_started_at": beautify_datetime,
46
- "job_finished_at": beautify_datetime,
47
- "created_at": beautify_datetime,
48
- }
icmd/core/session.py DELETED
@@ -1,347 +0,0 @@
1
- """Session class to handle ICMD API requests."""
2
-
3
- import json
4
- import logging
5
- import re
6
- import socket
7
- import socketserver
8
- import webbrowser
9
- from datetime import datetime, timedelta
10
- from getpass import getpass
11
- from http import HTTPStatus
12
- from pathlib import Path
13
-
14
- import requests.auth
15
- from IPython.display import HTML, display
16
-
17
- from .token_handler import TokenHandler
18
-
19
- TOKEN_EXPIRE_BUFFER: timedelta = timedelta(milliseconds=5000)
20
- CREDENTIAL_FILE = str(Path.home() / ".icmd_cred.json")
21
-
22
- SIGN_IN_METHODS = {"SAML", "PASSWORD"}
23
- AUTH_PORTS = [8001, 8002, 8003, 8004, 8005]
24
-
25
- def _find_available_port(port_list: list) -> int:
26
- """Find the first available port from the predefined list."""
27
- for port in port_list:
28
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
29
- try:
30
- s.bind(("127.0.0.1", port))
31
- return port
32
- except OSError:
33
- continue
34
- raise RuntimeError(f"No available ports found in the predefined list: {port_list}")
35
-
36
- class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
37
- """Handle requests in a separate thread."""
38
-
39
- allow_reuse_address = True
40
-
41
- class Session(requests.Session):
42
- """Wrapper around requests.Session that auto refresh token."""
43
-
44
- def __init__(self, jupyterlite: bool = False):
45
- super().__init__()
46
- self.api_version = "v1"
47
-
48
- self.sign_in_method: str = ""
49
- self.icmd_domain: str = ""
50
- self.username: str = ""
51
- self.password: str = ""
52
- self.mfa_code: str = ""
53
- self.auth_code: str = ""
54
- self.jupyterlite = jupyterlite
55
-
56
- self.refresh_token: str = ""
57
- self.id_token: str = ""
58
- self.id_token_expiration: datetime = datetime.utcnow()
59
- self.headers.update({"Content-Type": "application/json"})
60
-
61
- self._load_credential_file()
62
- if not jupyterlite:
63
- self._refresh_id_token()
64
-
65
- @property
66
- def api_root(self) -> str:
67
- """Return the root of the ICMD API, e.g., https://icmd.questek.com/api/v1."""
68
- if not self._is_valid_icmd_domain:
69
- self.icmd_domain = input("Your ICMD® domain (e.g., icmd.questek.com): ")
70
- if not self.icmd_domain.startswith("http"):
71
- self.icmd_domain = "https://" + self.icmd_domain
72
- self.sign_in_method = input(
73
- 'If you use SSO, please enter "saml"; otherwise, leave empty: '
74
- )
75
- self.sign_in_method = self.sign_in_method.strip().upper()
76
- if self.sign_in_method == "":
77
- self.sign_in_method = "SAML"
78
- if self.sign_in_method not in SIGN_IN_METHODS:
79
- raise ValueError("Invalid sign-in method.")
80
-
81
- self._write_credential_file()
82
- return f"{self.icmd_domain}/api/{self.api_version}"
83
-
84
- def _load_credential_file(self) -> None:
85
- """Load icmd_domain and refresh_token from the CREDENTIAL_FILE."""
86
- if not Path(CREDENTIAL_FILE).is_file():
87
- return
88
- try:
89
- with open(CREDENTIAL_FILE) as file:
90
- data = json.load(file)
91
- self.icmd_domain = data.get("ICMD_DOMAIN", "")
92
- self.sign_in_method = data.get("ICMD_SIGN_IN_METHOD", "")
93
- self.refresh_token = data.get("ICMD_REFRESH_TOKEN", "")
94
- except OSError:
95
- logging.warning(f"Cannot open credential file: {CREDENTIAL_FILE}")
96
- except json.JSONDecodeError:
97
- logging.warning(f"Cannot decode credential file in JSON: {CREDENTIAL_FILE}")
98
-
99
- def _write_credential_file(self) -> None:
100
- """Write current values to the CREDENTIAL_FILE."""
101
- data = {
102
- "ICMD_DOMAIN": self.icmd_domain,
103
- "ICMD_SIGN_IN_METHOD": self.sign_in_method,
104
- "ICMD_REFRESH_TOKEN": self.refresh_token,
105
- }
106
- with open(CREDENTIAL_FILE, "w") as file:
107
- json.dump(data, file)
108
-
109
- @property
110
- def _is_valid_icmd_domain(self) -> bool:
111
- """Check if the given domain matches the pattern of ICMD domains."""
112
- if not self.icmd_domain.startswith("http"):
113
- self.icmd_domain = "https://" + self.icmd_domain
114
- if self.icmd_domain.endswith("server.questek.com"):
115
- return True
116
- if self.icmd_domain.startswith("https"):
117
- pattern = r"^https://(\w*.)?icmd\.questek\.com"
118
- return bool(re.match(pattern, self.icmd_domain))
119
- return self.icmd_domain.startswith("http") # ignore local development
120
-
121
- @property
122
- def _is_id_token_expired(self):
123
- return self.id_token_expiration - TOKEN_EXPIRE_BUFFER <= datetime.utcnow()
124
-
125
- def _refresh_id_token(self) -> None:
126
- """Optionally refresh our id token."""
127
- url = f"{self.api_root}/account/auth/refresh/"
128
- response = self.request("post", url, json={"refreshToken": self.refresh_token})
129
- if response.status_code != HTTPStatus.OK:
130
- if self.sign_in_method.strip().upper() == "SAML":
131
- self._user_saml_sign_in()
132
- else:
133
- self._user_sign_in()
134
- else:
135
- self._handle_cookies(response)
136
- self._assign_tokens(response.json())
137
-
138
- def checked_request(self, method: str, path: str, **kwargs) -> requests.Response:
139
- """Make a request to the ICMD API and handle token expiration."""
140
- clean_path = path.strip("/")
141
- if self._is_id_token_expired:
142
- self._refresh_id_token()
143
-
144
- url = f"{self.api_root}/{clean_path}/"
145
- response = self.request(method, url, **kwargs)
146
-
147
- if response.status_code == HTTPStatus.UNAUTHORIZED and response.reason == "Unauthorized":
148
- self._refresh_id_token()
149
- response = self.request(method, url, **kwargs)
150
-
151
- return response
152
-
153
- def _assign_tokens(self, auth: dict) -> None:
154
- """Assign tokens from auth response."""
155
- self.headers.update({"X-User-Context": auth.get("userFingerprint")})
156
- self.id_token = auth.get("idToken")
157
- if auth.get("refreshToken"):
158
- self.refresh_token = auth.get("refreshToken")
159
- self.id_token_expiration = datetime.fromisoformat(auth.get("expiresAt"))
160
- self.auth = BearerAuth(auth.get("idToken"))
161
-
162
- def _handle_cookies(self, response: requests.Response) -> None:
163
- """Handle cookies from response."""
164
- for cookie in response.cookies:
165
- if cookie.name == "Secure-Fgp":
166
- self.cookies.set("Secure-Fgp", cookie.value)
167
-
168
- def _mfa_sign_in(self, challenge, username, challenge_name) -> None:
169
- """Sign in with MFA code."""
170
- session = challenge["Session"]
171
- if not self.mfa_code:
172
- self.mfa_code = input("Your MFA Code: ")
173
- if username is None:
174
- username = input("Your ICMD® email: ")
175
- url = f"{self.api_root}/account/auth/mfa/"
176
- response = self.request(
177
- "post",
178
- url,
179
- json={
180
- "mfa_code": self.mfa_code,
181
- "session": session,
182
- "username": username,
183
- "challenge_name": challenge_name,
184
- },
185
- )
186
- if response.status_code != HTTPStatus.OK:
187
- raise ValueError(str(response.json()))
188
- self._handle_cookies(response)
189
- self._assign_tokens(response.json())
190
-
191
- def _handle_sign_in_response(self, username, response: requests.Response) -> None:
192
- """Handle response from login API endpoint."""
193
- if response.status_code != HTTPStatus.OK:
194
- raise ValueError("Cannot sign into ICMD®: " + str(response.json()))
195
- self._handle_cookies(response)
196
- auth = response.json()
197
- if auth and auth.get("challenge"):
198
- challenge = auth["challenge"]
199
- challenge_name = challenge.get("ChallengeName")
200
- if challenge_name in {"SOFTWARE_TOKEN_MFA", "SMS_MFA"}:
201
- self._mfa_sign_in(challenge, username, challenge_name)
202
- else:
203
- raise ValueError("Unknown challenge - " + challenge_name)
204
- else:
205
- self._assign_tokens(auth)
206
-
207
- def _user_sign_in(self):
208
- """Let user sign in with password to fetch a refresh token."""
209
- if not self.username:
210
- self.username = input("Your ICMD® email: ")
211
- self.password = getpass("Your ICMD® password: ")
212
- self.mfa_code = input("Your MFA Code (if enabled): ")
213
-
214
- try:
215
- url = f"{self.api_root}/account/auth/login/"
216
- response = self.request(
217
- "post", url, json={"username": self.username, "password": self.password}
218
- )
219
- self._handle_sign_in_response(self.username, response)
220
- except Exception as e:
221
- raise ValueError("Cannot sign into ICMD®: " + str(e)) from e
222
-
223
- self._write_credential_file()
224
-
225
- def _user_saml_sign_in(self):
226
- """Let user sign in with SAML to fetch a refresh token."""
227
- try:
228
- if self.jupyterlite:
229
- self._browser_env_saml_sign_in()
230
- else:
231
- print("\n\033[94mSigning into ICMD® with SAML SSO...\033[0m\n")
232
-
233
- LOCAL_PORT = _find_available_port(AUTH_PORTS)
234
- consent_login_url = f"{self.api_root}/account/auth/consent?redirect_uri=http://localhost:{LOCAL_PORT}/callback"
235
-
236
- print("\n\033[94mConsent granted. Proceeding with SSO authentication...\033[0m\n")
237
-
238
- print("Automatically opening the SSO authorization page in your default browser. "
239
- "If it doesn't open or you prefer to use a different browser, "
240
- "go to this URL:")
241
-
242
- print(f"\n{consent_login_url}\n")
243
- webbrowser.open(consent_login_url)
244
-
245
- # Start a local server to capture the auth_code
246
- with socketserver.TCPServer(("localhost", LOCAL_PORT), TokenHandler) as httpd:
247
- httpd.handle_request() # Wait for a single request, blocking
248
- auth_code = httpd.auth_code
249
- if not auth_code:
250
- raise ValueError("Authentication code was not received.")
251
-
252
- login_url = f"{self.api_root}/account/auth/login/"
253
- response = self.request("post", login_url, json=
254
- {
255
- "code": auth_code,
256
- "redirect_uri": f"http://localhost:{LOCAL_PORT}/callback"
257
- })
258
- self._handle_sign_in_response(None, response)
259
- print("\n\033[92mSigned in successfully.\033[0m\n")
260
- except Exception as e:
261
- print("\nCannot sign into ICMD®: " + str(e) + "\n")
262
- finally:
263
- pass
264
-
265
- def _browser_env_saml_sign_in(self):
266
- """Show a button for signing in with SAML in JupyterLite."""
267
- try:
268
- # Create a sign-in button
269
- consent_login_url = f"{self.api_root}/account/auth/consent"
270
- buttomHTML = f"""
271
- <a type="button" href="{consent_login_url}" target="_blank">Sign in with SAML SSO</a>
272
- """
273
- display(HTML(buttomHTML))
274
- except Exception as e:
275
- print(f"\nCannot sign in: {e}\n")
276
-
277
- def complete_saml_sign_in(self):
278
- """Complete the SAML sign in process."""
279
- if not self.auth_code:
280
- print("\033[91mPlease enter an authorization code.\033[0m")
281
- return
282
- try:
283
- login_url = f"{self.api_root}/account/auth/login/"
284
- response = self.request("post", login_url, json={
285
- "code": self.auth_code,
286
- "redirect_uri": f"{self.api_root}/account/auth/exchange"
287
- })
288
- self._handle_sign_in_response(None, response)
289
- print("\n\033[92mSigned in successfully.\033[0m\n")
290
- except Exception as e:
291
- print(f"\n\033[91mError during sign in: {e!s}\033[0m\n")
292
-
293
- def jupyterlite_sign_in(self):
294
- """Sign in on JupyterLite."""
295
- if not self.icmd_domain or not self.sign_in_method:
296
- raise ValueError(
297
- "Please provide all following necessary information to sign in:\n"
298
- "session.icmd_domain = await input('icmd_domain'), e.g., icmd.questek.com\n"
299
- "session.sign_in_method = await input('sign_in_method'), e.g., PASSWORD or SAML\n"
300
- )
301
-
302
- if self.sign_in_method == "PASSWORD" and not all(
303
- [
304
- self.username,
305
- self.password,
306
- self.mfa_code,
307
- ]
308
- ):
309
- raise ValueError(
310
- "Please provide all following necessary information to sign in:\n"
311
- "session.username = await input('username'), i.e., your email\n"
312
- "session.password = await getpass('password'), i.e., your password\n"
313
- "session.mfa_code = await input('mfa_code'), i.e., the 6 digit code for MFA\n"
314
- )
315
-
316
- if self.sign_in_method == "SAML" and not all(
317
- [self.username]
318
- ):
319
- raise ValueError(
320
- "Please provide all following necessary information to sign in:\n"
321
- "session.username = await input('username'), i.e., your email\n"
322
- )
323
- self._refresh_id_token()
324
-
325
- @staticmethod
326
- def extract_code(url):
327
- """Extract authorization code from the url."""
328
- start = url.find("code=")
329
- if start == -1:
330
- return None
331
- start += len("code=")
332
- end = url.find(" ", start)
333
- if end == -1:
334
- end = len(url)
335
- return url[start:end]
336
-
337
-
338
- class BearerAuth(requests.auth.AuthBase):
339
- """A lightweight Auth class to support Bearer token auth header."""
340
-
341
- def __init__(self, token):
342
- self.token = token
343
-
344
- def __call__(self, r):
345
- """Attach an Authorization header to the given Request object."""
346
- r.headers["Authorization"] = f"Bearer {self.token}"
347
- return r
@@ -1,36 +0,0 @@
1
- """
2
- TokenHandler class for processing SAML callbacks.
3
-
4
- This module implements a custom HTTP request handler for processing
5
- SAML callbacks. It listens for authorization codes in the callback
6
- requests and responds with an HTML template indicating success.
7
- """
8
-
9
- import os
10
- from http.server import SimpleHTTPRequestHandler
11
-
12
-
13
- class TokenHandler(SimpleHTTPRequestHandler):
14
- """Custom HTTP request handler to process the SAML callback."""
15
-
16
- def do_GET(self):
17
- """Handle GET request to capture the auth code."""
18
- if "/callback?" in self.path:
19
- query_data = self.path.split("?")[1]
20
- token_info = dict(qc.split("=") for qc in query_data.split("&"))
21
- auth_code = token_info.get("code")
22
-
23
- if auth_code:
24
- # Send a response back to the browser indicating success
25
- self.send_response(200)
26
- self.send_header("Content-type", "text/html")
27
- self.end_headers()
28
-
29
- file_path = os.path.join(os.path.dirname(__file__), "template/callback.html")
30
- with open(file_path, "rb") as file:
31
- html_content = file.read()
32
-
33
- self.wfile.write(html_content)
34
- self.server.auth_code = auth_code
35
- else:
36
- self.send_error(400, "Authorization code not found")
@@ -1,13 +0,0 @@
1
- icmd/__init__.py,sha256=RKbCk0KLWswT4w2Ahfg15MGBhtOG70godi63UAQmjng,67
2
- icmd/version.py,sha256=XxXhu8-QnuD9hA8Ah0WX5rgpt_DwOQmAwcK-FtpngyQ,22
3
- icmd/clients/__init__.py,sha256=cKiAGFDou9bwES19V6qwlMtG1rZVi963SoqCWzqQn2Y,32
4
- icmd/clients/calphad_client.py,sha256=TTMOJilkIRHdTsSuyn3QMOcCCZ-H_W6ebKpBp9z6Kq8,3387
5
- icmd/core/__init__.py,sha256=11bBl3MBG106aN3VhYi24CqNKMhE2eAjhvQ4cFVmXsc,102
6
- icmd/core/base_client.py,sha256=1u-BJd3vzcG0MWAmhQbxwyzhpsPp7wj8pRfdJmW9R44,934
7
- icmd/core/job_table_format.py,sha256=USo0eN40EnE9esfcscP0EWRK-6Zo8_8FIcjGRNvhruc,1473
8
- icmd/core/session.py,sha256=mMWO5a8IzFn_bUcjpdetZaDtStAZLKzN6sqInHThuDw,13934
9
- icmd/core/token_handler.py,sha256=mVMJSnQ2Y5smytykdxcBrgkMWpfP8nw0oIYdY23hGrc,1343
10
- icmd_python-1.1.4.dist-info/METADATA,sha256=6glHN1VBRl7jPzkXxD0BnoBQF0nDM6WOvy1biE1kDCQ,478
11
- icmd_python-1.1.4.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
12
- icmd_python-1.1.4.dist-info/top_level.txt,sha256=olzM6Z_QEwg7RrFVOnkgO0I2Ps2m7vhSqYQZYeiawcw,5
13
- icmd_python-1.1.4.dist-info/RECORD,,