icmd-python 1.1.4__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 ADDED
@@ -0,0 +1,3 @@
1
+ """Python client app for ICMD® API."""
2
+
3
+ from .core import Session
@@ -0,0 +1 @@
1
+ """Client apps for ICMD API."""
@@ -0,0 +1,93 @@
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/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Core module for icmd package."""
2
+
3
+ from .base_client import BaseClient
4
+ from .session import Session
@@ -0,0 +1,29 @@
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}/"
@@ -0,0 +1,48 @@
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 ADDED
@@ -0,0 +1,347 @@
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
@@ -0,0 +1,36 @@
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")
icmd/version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.1.4"
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.1
2
+ Name: icmd-python
3
+ Version: 1.1.4
4
+ Summary: Python client app for ICMD®
5
+ Author: QuesTek Innovations LLC
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: requests
8
+ Provides-Extra: dev
9
+ Requires-Dist: myst-nb; extra == "dev"
10
+ Requires-Dist: pytest; extra == "dev"
11
+ Requires-Dist: pytest-xdist; extra == "dev"
12
+ Requires-Dist: ruff; extra == "dev"
13
+ Requires-Dist: sphinx; extra == "dev"
14
+ Requires-Dist: sphinx-rtd-theme; extra == "dev"
15
+ Requires-Dist: pre-commit; extra == "dev"
@@ -0,0 +1,13 @@
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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.6.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ icmd