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 +3 -1
- icmd/core/__init__.py +11 -3
- icmd/core/client.py +482 -0
- icmd/core/credentials.py +144 -0
- icmd/version.py +1 -1
- {icmd_python-1.1.4.dist-info → icmd_python-1.2.3.dist-info}/METADATA +4 -3
- icmd_python-1.2.3.dist-info/RECORD +9 -0
- {icmd_python-1.1.4.dist-info → icmd_python-1.2.3.dist-info}/WHEEL +1 -1
- icmd/clients/__init__.py +0 -1
- icmd/clients/calphad_client.py +0 -93
- icmd/core/base_client.py +0 -29
- icmd/core/job_table_format.py +0 -48
- icmd/core/session.py +0 -347
- icmd/core/token_handler.py +0 -36
- icmd_python-1.1.4.dist-info/RECORD +0 -13
- {icmd_python-1.1.4.dist-info → icmd_python-1.2.3.dist-info}/top_level.txt +0 -0
icmd/__init__.py
CHANGED
icmd/core/__init__.py
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
"""Core
|
|
1
|
+
"""Core ICMD client modules."""
|
|
2
2
|
|
|
3
|
-
from .
|
|
4
|
-
from .
|
|
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)
|
icmd/core/credentials.py
ADDED
|
@@ -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
|
+
__version__ = "1.2.3"
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: icmd-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.3
|
|
4
4
|
Summary: Python client app for ICMD®
|
|
5
5
|
Author: QuesTek Innovations LLC
|
|
6
|
-
Requires-Python: >=3.
|
|
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,,
|
icmd/clients/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Client apps for ICMD API."""
|
icmd/clients/calphad_client.py
DELETED
|
@@ -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}/"
|
icmd/core/job_table_format.py
DELETED
|
@@ -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
|
icmd/core/token_handler.py
DELETED
|
@@ -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,,
|
|
File without changes
|