icmd-python 1.5.7__py3-none-any.whl → 1.6.5__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.
icmd/__init__.py CHANGED
@@ -1,5 +1,17 @@
1
1
  """Python client app for ICMD® API."""
2
2
 
3
- from .core import ICMD
3
+ from .core import (
4
+ ICMD,
5
+ ICMDAuthenticationError,
6
+ ICMDConnectionError,
7
+ ICMDRequestError,
8
+ ICMDValidationError,
9
+ )
4
10
 
5
- __all__ = ["ICMD"]
11
+ __all__ = [
12
+ "ICMD",
13
+ "ICMDAuthenticationError",
14
+ "ICMDConnectionError",
15
+ "ICMDRequestError",
16
+ "ICMDValidationError",
17
+ ]
icmd/core/__init__.py CHANGED
@@ -1,12 +1,29 @@
1
1
  """Core ICMD client modules."""
2
2
 
3
- from .client import ICMD, ICMDAuthenticationError, ICMDConnectionError, ICMDValidationError
3
+ from . import urls
4
+ from .apps import Design, Fatigue, Resource, Workspace
5
+ from .client import (
6
+ ICMD,
7
+ ICMDAuthenticationError,
8
+ ICMDConnectionError,
9
+ ICMDRequestError,
10
+ ICMDValidationError,
11
+ )
4
12
  from .credentials import CredentialManager
13
+ from .display import display_designs, display_jobs
5
14
 
6
15
  __all__ = [
7
16
  "ICMD",
8
17
  "CredentialManager",
18
+ "Design",
19
+ "Fatigue",
9
20
  "ICMDAuthenticationError",
10
21
  "ICMDConnectionError",
22
+ "ICMDRequestError",
11
23
  "ICMDValidationError",
24
+ "Resource",
25
+ "Workspace",
26
+ "display_designs",
27
+ "display_jobs",
28
+ "urls",
12
29
  ]
icmd/core/apps.py ADDED
@@ -0,0 +1,168 @@
1
+ """Composition-based resource access for ICMD API.
2
+
3
+ Provides a clean nested-attribute API::
4
+
5
+ icmd = ICMD("icmd.questek.com")
6
+ icmd.design.job.list(page=1, page_size=10)
7
+ icmd.workspace.dataset.get("uuid-123")
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from functools import partial
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from . import urls
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable
19
+
20
+ from .client import ICMD
21
+
22
+
23
+ class Resource:
24
+ """Generic API resource with list/get/add operations.
25
+
26
+ Parameters
27
+ ----------
28
+ client : ICMD
29
+ The ICMD client instance.
30
+ list_url : Callable[[], str]
31
+ Callable that returns the list endpoint.
32
+ item_url : Callable[[str], str] | None
33
+ Callable that accepts a UUID and returns the detail endpoint.
34
+ If None, 'get()' raises 'NotImplementedError'.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ client: ICMD,
40
+ list_url: Callable[[], str],
41
+ item_url: Callable[[str], str] | None = None,
42
+ ) -> None:
43
+ self._client = client
44
+ self._list_url = list_url
45
+ self._item_url = item_url
46
+
47
+ def list(self, **params: Any) -> dict | list[dict]:
48
+ """List resources, forwarding query parameters."""
49
+ return self._client._request_json("get", self._list_url(), params=params or None)
50
+
51
+ def get(self, uuid: str) -> dict | list[dict]:
52
+ """Get a single resource by UUID."""
53
+ if self._item_url is None:
54
+ raise NotImplementedError("This resource does not support get()")
55
+ return self._client._request_json("get", self._item_url(uuid))
56
+
57
+ def add(self, data: Any) -> dict | list[dict]:
58
+ """Create a new resource."""
59
+ return self._client._request_json("post", self._list_url(), json=data)
60
+
61
+
62
+ class ReadOnlyResource:
63
+ """Resource that only supports get-by-UUID (e.g. results).
64
+
65
+ Parameters
66
+ ----------
67
+ client : ICMD
68
+ The ICMD client instance.
69
+ item_url : Callable[[str], str]
70
+ Callable that accepts a UUID and returns the detail endpoint.
71
+ """
72
+
73
+ def __init__(self, client: ICMD, item_url: Callable[[str], str]) -> None:
74
+ self._client = client
75
+ self._item_url = item_url
76
+
77
+ def get(self, uuid: str) -> dict | list[dict]:
78
+ """Get a single resource by UUID."""
79
+ return self._client._request_json("get", self._item_url(uuid))
80
+
81
+
82
+ class DesignResource(Resource):
83
+ """Resource for toolkit designs — auto-attaches 'owner_group' on add().
84
+
85
+ Parameters
86
+ ----------
87
+ client : ICMD
88
+ The ICMD client instance.
89
+ list_url : Callable[[], str]
90
+ Callable that returns the list endpoint.
91
+ item_url : Callable[[str], str] | None
92
+ Callable that accepts a UUID and returns the detail endpoint.
93
+ """
94
+
95
+ def add(
96
+ self,
97
+ data: dict | None = None,
98
+ design_name: str | None = None,
99
+ share_with_group: bool = True,
100
+ ) -> dict | list[dict]:
101
+ """Create a new design, auto-attaching owner_group.
102
+
103
+ Parameters
104
+ ----------
105
+ data : dict | None
106
+ Payload for the design. If None, an empty dict is used.
107
+ design_name : str | None
108
+ Optional name for the design (sets 'name' key).
109
+ share_with_group : bool
110
+ Whether to share with the owner group (default True).
111
+ """
112
+ payload: dict[str, Any] = dict(data) if data else {}
113
+ payload["owner_group"] = self._client.owner_group
114
+ if design_name is not None:
115
+ payload["name"] = design_name
116
+ payload["share_with_group"] = share_with_group
117
+ return self._client._request_json("post", self._list_url(), json=payload)
118
+
119
+
120
+ class _ToolkitApp:
121
+ """Private base for toolkit apps (Design, Fatigue).
122
+
123
+ Sets up 'job', 'design', 'model', and 'result' sub-resources
124
+ for the given toolkit name.
125
+ """
126
+
127
+ def __init__(self, client: ICMD, toolkit: str) -> None:
128
+ self.job = Resource(
129
+ client,
130
+ partial(urls.jobs, toolkit=toolkit),
131
+ partial(urls.job, toolkit=toolkit),
132
+ )
133
+ self.design = DesignResource(
134
+ client,
135
+ partial(urls.designs, toolkit=toolkit),
136
+ partial(urls.design, toolkit=toolkit),
137
+ )
138
+ self.model = Resource(
139
+ client,
140
+ partial(urls.models, toolkit=toolkit),
141
+ item_url=None,
142
+ )
143
+ self.result = ReadOnlyResource(
144
+ client,
145
+ partial(urls.results, toolkit=toolkit),
146
+ )
147
+
148
+
149
+ class Design(_ToolkitApp):
150
+ """Design Alloy toolkit resources."""
151
+
152
+ def __init__(self, client: ICMD) -> None:
153
+ super().__init__(client, "design")
154
+
155
+
156
+ class Fatigue(_ToolkitApp):
157
+ """Fatigue Simulation toolkit resources."""
158
+
159
+ def __init__(self, client: ICMD) -> None:
160
+ super().__init__(client, "fatigue")
161
+
162
+
163
+ class Workspace:
164
+ """Workspace resources (datasets, ML models)."""
165
+
166
+ def __init__(self, client: ICMD) -> None:
167
+ self.dataset = Resource(client, urls.datasets, urls.dataset)
168
+ self.ml_model = Resource(client, urls.ml_models, urls.ml_model)
icmd/core/client.py CHANGED
@@ -5,7 +5,8 @@ import webbrowser
5
5
  from datetime import UTC, datetime, timedelta
6
6
  from getpass import getpass
7
7
  from http import HTTPStatus
8
- from typing import TYPE_CHECKING, Any, ClassVar
8
+ from typing import TYPE_CHECKING, Any, ClassVar, Literal
9
+ from urllib.parse import quote
9
10
 
10
11
  if TYPE_CHECKING:
11
12
  from pathlib import Path
@@ -13,6 +14,8 @@ if TYPE_CHECKING:
13
14
  import requests
14
15
  from requests.adapters import HTTPAdapter
15
16
 
17
+ from .apps import Design, Fatigue, Workspace
18
+ from .constants import GROUPS
16
19
  from .credentials import CredentialManager
17
20
  from .slim_packages import ensure_slim_packages
18
21
 
@@ -29,6 +32,26 @@ class ICMDValidationError(Exception):
29
32
  """Raised when input validation fails."""
30
33
 
31
34
 
35
+ class ICMDRequestError(Exception):
36
+ """Raised when an API request fails.
37
+
38
+ Attributes
39
+ ----------
40
+ status_code: HTTP status code from the failed request.
41
+ response_text: Raw response body text for debugging.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ message: str,
47
+ status_code: int | None = None,
48
+ response_text: str | None = None,
49
+ ):
50
+ self.status_code = status_code
51
+ self.response_text = response_text
52
+ super().__init__(message)
53
+
54
+
32
55
  class ICMD:
33
56
  """ICMD client - supports multiple domains per process."""
34
57
 
@@ -108,6 +131,11 @@ class ICMD:
108
131
  self._server_config_cache = None
109
132
 
110
133
  self._credentials = CredentialManager()
134
+ self._owner_group: int | None = None
135
+
136
+ self.design: Design = Design(self)
137
+ self.fatigue: Fatigue = Fatigue(self)
138
+ self.workspace: Workspace = Workspace(self)
111
139
 
112
140
  # Clear cached credentials if requested
113
141
  if clear_cache:
@@ -117,7 +145,9 @@ class ICMD:
117
145
  if developer_token:
118
146
  self._session.headers["Authorization"] = f"Bearer {developer_token}"
119
147
  self._developer_token = developer_token
120
- # Skip all auth flows for developer tokens
148
+ # Don't fail ICMD initialization if slim package management fails
149
+ with contextlib.suppress(Exception):
150
+ ensure_slim_packages(self)
121
151
  return
122
152
 
123
153
  self._developer_token = None
@@ -613,11 +643,8 @@ class ICMD:
613
643
 
614
644
  except KeyboardInterrupt:
615
645
  # Default to first provider on Ctrl+C
616
- print(
617
- f"\nDefaulting to {
618
- saml_providers[0].get('name', saml_providers[0]['id'])
619
- }..."
620
- )
646
+ provider_name = saml_providers[0].get("name", saml_providers[0]["id"])
647
+ print(f"\nDefaulting to {provider_name}...")
621
648
  return saml_providers[0]
622
649
 
623
650
  except EOFError:
@@ -631,8 +658,6 @@ class ICMD:
631
658
  def _saml_authentication(self) -> None:
632
659
  """Authenticate with Django SAML SSO."""
633
660
  try:
634
- from urllib.parse import quote
635
-
636
661
  # Get SAML provider configuration
637
662
  provider = self._get_saml_provider()
638
663
 
@@ -783,6 +808,27 @@ class ICMD:
783
808
  """DELETE request."""
784
809
  return self.request("DELETE", endpoint, **kwargs)
785
810
 
811
+ def upload(self, endpoint: str, data: Any = None, **kwargs) -> requests.Response:
812
+ """Upload data to an endpoint.
813
+
814
+ Alias for post() - provides more intuitive naming for data submission.
815
+
816
+ Parameters
817
+ ----------
818
+ endpoint : str
819
+ API endpoint to upload to.
820
+ data : Any
821
+ Data to upload (will be JSON serialized).
822
+ **kwargs : Any
823
+ Additional arguments passed to the request method.
824
+
825
+ Returns
826
+ -------
827
+ requests.Response
828
+ Response from the API.
829
+ """
830
+ return self.post(endpoint, data, **kwargs)
831
+
786
832
  def logout(self, clear_saved_credentials: bool = False) -> None:
787
833
  """Logout and clear session.
788
834
 
@@ -811,3 +857,153 @@ class ICMD:
811
857
  self._credentials.clear_refresh_token(self.domain)
812
858
 
813
859
  print("Logged out successfully.")
860
+
861
+ # -------------------------------------------------------------------------
862
+ # High-level API helper methods
863
+ # -------------------------------------------------------------------------
864
+
865
+ @staticmethod
866
+ def _check_response(resp: requests.Response, operation: str | None = None) -> None:
867
+ """Determine if there was an error obtaining response and if so raise exception.
868
+
869
+ Parameters
870
+ ----------
871
+ resp : Response
872
+ The response object to check.
873
+ operation : str | None
874
+ Description of the operation being performed, for error context.
875
+ """
876
+ if not (HTTPStatus.OK <= resp.status_code < HTTPStatus.MULTIPLE_CHOICES):
877
+ if operation:
878
+ message = f"Failed to {operation}: status {resp.status_code}. Message: {resp.text}"
879
+ else:
880
+ message = f"Request failed with status: {resp.status_code}. Message: {resp.text}"
881
+ raise ICMDRequestError(
882
+ message,
883
+ status_code=resp.status_code,
884
+ response_text=resp.text,
885
+ )
886
+
887
+ def _request_json(
888
+ self,
889
+ method: Literal["get", "post", "put", "delete"],
890
+ endpoint: str,
891
+ operation: str | None = None,
892
+ **kwargs: Any,
893
+ ) -> dict | list[dict]:
894
+ """Make a request and return the JSON response.
895
+
896
+ Parameters
897
+ ----------
898
+ method : Literal["get", "post", "post", "delete"]
899
+ HTTP method ('get', 'post', 'put', 'delete').
900
+ endpoint : str
901
+ API endpoint.
902
+ operation : str | None
903
+ Description of the operation being performed, for error context.
904
+ **kwargs : Any
905
+ Additional arguments passed to the request method.
906
+
907
+ Returns
908
+ -------
909
+ dict | list[dict]
910
+ Parsed JSON response as a dict or list of dicts.
911
+
912
+ Raises
913
+ ------
914
+ ICMDRequestError
915
+ If the request fails.
916
+ """
917
+ resp = self.request(method.upper(), endpoint, **kwargs)
918
+ self._check_response(resp, operation)
919
+ return resp.json()
920
+
921
+ def _get_owner_group_id(self, group_name: str | None = None) -> int:
922
+ """Return the group ID of a specific user group.
923
+
924
+ Parameters
925
+ ----------
926
+ group_name : str | None
927
+ Name of the group for which to obtain the ID.
928
+ If not specified, retrieves the ID of the first group listed in the user account.
929
+
930
+ Returns
931
+ -------
932
+ int
933
+ The group ID.
934
+
935
+ Raises
936
+ ------
937
+ ICMDRequestError
938
+ If no groups are found for the user or group data is malformed.
939
+ ValueError
940
+ If group_name is specified but no group with that name exists.
941
+ """
942
+ groups = self._request_json("get", GROUPS, operation="fetch owner group")
943
+ if not isinstance(groups, list) or not groups:
944
+ raise ICMDRequestError("No groups found for user")
945
+ if not isinstance(groups[0], dict) or "id" not in groups[0]:
946
+ raise ICMDRequestError("Group data missing 'id' field")
947
+ if group_name is not None:
948
+ groups_with_group_name = [group for group in groups if group["name"] == group_name]
949
+ if len(groups_with_group_name) == 0:
950
+ raise ValueError(f"No group found with name '{group_name}'") from None
951
+ return groups_with_group_name[0]["id"]
952
+ return groups[0]["id"]
953
+
954
+ def set_owner_group(self, group_name: str | None = None) -> None:
955
+ """Set the owner group for subsequent API operations.
956
+
957
+ Parameters
958
+ ----------
959
+ group_name : str | None
960
+ Name of the owner group. If not specified, uses the first group
961
+ listed in the user account.
962
+
963
+ Raises
964
+ ------
965
+ ICMDRequestError
966
+ If no groups are found for the user or group data is malformed.
967
+ ValueError
968
+ If group_name is specified but no group with that name exists.
969
+ """
970
+ self._owner_group = self._get_owner_group_id(group_name)
971
+
972
+ def get_owner_group_names(self) -> list[str]:
973
+ """Get all available owner group names for the current user.
974
+
975
+ Returns
976
+ -------
977
+ list[str]
978
+ List of group names available to the user.
979
+
980
+ Raises
981
+ ------
982
+ ICMDRequestError
983
+ If no groups are found for the user or group data is malformed.
984
+ """
985
+ groups = self._request_json("get", GROUPS, operation="fetch owner groups")
986
+ if not isinstance(groups, list) or not groups:
987
+ raise ICMDRequestError("No groups found for user")
988
+ return [group["name"] for group in groups]
989
+
990
+ @property
991
+ def owner_group(self) -> int:
992
+ """Return the owner group ID of the current user.
993
+
994
+ The owner group is fetched lazily on first access and cached
995
+ for subsequent calls.
996
+
997
+ Returns
998
+ -------
999
+ int
1000
+ The owner group ID.
1001
+
1002
+ Raises
1003
+ ------
1004
+ ICMDRequestError
1005
+ If fetching the owner group fails.
1006
+ """
1007
+ if self._owner_group is None:
1008
+ self._owner_group = self._get_owner_group_id()
1009
+ return self._owner_group
icmd/core/constants.py ADDED
@@ -0,0 +1,12 @@
1
+ """API endpoint constants for ICMD client."""
2
+
3
+ # API endpoint constants
4
+ MACHINE_LEARNING_MODELS: str = "workspace/mlmodels"
5
+ DATASETS: str = "workspace/dataset"
6
+ DESIGN_TOOLKIT: str = "design_alloy"
7
+ FATIGUE_TOOLKIT: str = "simulation_fatigue"
8
+ GROUPS: str = "account/groups"
9
+ QT_MODEL: str = "qt_model"
10
+ JOB: str = "job"
11
+ RESULTS: str = "results"
12
+ DESIGN: str = "design"
icmd/core/display.py ADDED
@@ -0,0 +1,143 @@
1
+ """Display utilities for ICMD data.
2
+
3
+ This module provides interactive display functions for designs and jobs.
4
+
5
+ Example usage:
6
+ from icmd import ICMD
7
+ from icmd.core.display import display_designs, display_jobs
8
+
9
+ client = ICMD("icmd.questek.com")
10
+ display_designs(client, toolkit="design")
11
+ display_jobs(client, toolkit="fatigue")
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING, Literal
17
+
18
+ from . import urls
19
+ from .pagination import handle_pagination_input, print_pagination_info, print_table
20
+
21
+ if TYPE_CHECKING:
22
+ from .client import ICMD
23
+
24
+
25
+ def display_designs(
26
+ client: ICMD,
27
+ toolkit: Literal["design", "fatigue"] = "design",
28
+ page_number: int = 1,
29
+ page_size: int = 10,
30
+ interactive: bool = True,
31
+ ) -> None:
32
+ """Display designs in a paginated table.
33
+
34
+ Parameters
35
+ ----------
36
+ client : ICMD
37
+ Authenticated ICMD client instance.
38
+ toolkit : Literal["design", "fatigue"]
39
+ Toolkit from which to display designs. Default is "design".
40
+ page_number : int
41
+ Starting page number. Default is 1.
42
+ page_size : int
43
+ Number of designs per page. Default is 10.
44
+ interactive : bool
45
+ If True, allows navigation between pages. Default is True.
46
+ """
47
+ current_page = page_number
48
+ total_items = client.get(urls.designs(toolkit)).json()["count"]
49
+ total_pages = (total_items + page_size - 1) // page_size
50
+ toolkit_name = urls._toolkit(toolkit).replace("_", " ").title()
51
+
52
+ while True:
53
+ response = client.get(
54
+ urls.designs(toolkit),
55
+ params={"page": int(current_page), "page_size": int(page_size)},
56
+ ).json()
57
+ designs = response["data"]
58
+
59
+ rows = [
60
+ [
61
+ d.get("uuid", "N/A"),
62
+ d.get("name", "Unnamed"),
63
+ d.get("last_updated", "N/A")[:19] if d.get("last_updated") else "N/A",
64
+ str(d.get("jobs_count", "N/A")),
65
+ ]
66
+ for d in designs
67
+ ]
68
+ print_table(
69
+ ["UUID", "Name", "Last Updated", "Number of Jobs"],
70
+ rows,
71
+ [36, 30, 20, 15],
72
+ toolkit_name,
73
+ "designs",
74
+ )
75
+ print_pagination_info(current_page, page_size, total_items)
76
+
77
+ if not interactive:
78
+ break
79
+
80
+ current_page = handle_pagination_input(current_page, total_pages)
81
+ if current_page is None:
82
+ break
83
+
84
+
85
+ def display_jobs(
86
+ client: ICMD,
87
+ toolkit: Literal["design", "fatigue"] = "design",
88
+ page_number: int = 1,
89
+ page_size: int = 10,
90
+ interactive: bool = True,
91
+ ) -> None:
92
+ """Display jobs in a paginated table.
93
+
94
+ Parameters
95
+ ----------
96
+ client : ICMD
97
+ Authenticated ICMD client instance.
98
+ toolkit : Literal["design", "fatigue"]
99
+ Toolkit from which to display jobs. Default is "design".
100
+ page_number : int
101
+ Starting page number. Default is 1.
102
+ page_size : int
103
+ Number of jobs per page. Default is 10.
104
+ interactive : bool
105
+ If True, allows navigation between pages. Default is True.
106
+ """
107
+ current_page = page_number
108
+ total_items = client.get(urls.jobs(toolkit)).json()["count"]
109
+ total_pages = (total_items + page_size - 1) // page_size
110
+ toolkit_name = urls._toolkit(toolkit).replace("_", " ").title()
111
+
112
+ while True:
113
+ response = client.get(
114
+ urls.jobs(toolkit),
115
+ params={"page": int(current_page), "page_size": int(page_size)},
116
+ ).json()
117
+ jobs = response["data"]
118
+
119
+ rows = [
120
+ [
121
+ j.get("uuid", "N/A"),
122
+ j.get("design_name", j.get("design", {}).get("name", "N/A"))[:30],
123
+ j.get("job_status", "N/A"),
124
+ j.get("created_at", "N/A")[:19] if j.get("created_at") else "N/A",
125
+ ]
126
+ for j in jobs
127
+ ]
128
+
129
+ print_table(
130
+ ["UUID", "Design", "Status", "Created"],
131
+ rows,
132
+ [36, 30, 15, 20],
133
+ toolkit_name,
134
+ "jobs",
135
+ )
136
+ print_pagination_info(current_page, page_size, total_items)
137
+
138
+ if not interactive:
139
+ break
140
+
141
+ current_page = handle_pagination_input(current_page, total_pages)
142
+ if current_page is None:
143
+ break
@@ -0,0 +1,91 @@
1
+ """Pagination helpers for ICMD client."""
2
+
3
+
4
+ def print_table(
5
+ headers: list[str],
6
+ rows: list[list[str]],
7
+ col_widths: list[int],
8
+ toolkit_name: str,
9
+ list_type: str,
10
+ ) -> None:
11
+ """Print a formatted table to the console.
12
+
13
+ Parameters
14
+ ----------
15
+ headers : list[str]
16
+ Column headers.
17
+ rows : list[list[str]]
18
+ Table rows.
19
+ col_widths : list[int]
20
+ Width for each column.
21
+ toolkit_name : str
22
+ Toolkit name (e.g., "Fatigue Simulation").
23
+ list_type : str
24
+ Type of items being listed (e.g., "jobs" or "designs"
25
+ """
26
+ border_length = 80
27
+ print(f"\n{'=' * border_length}")
28
+ print(f"{list_type.title()} - {toolkit_name.title()} Toolkit")
29
+ print("=" * border_length)
30
+ header_row = " | ".join(h.ljust(w) for h, w in zip(headers, col_widths, strict=False))
31
+ separator = "-+-".join("-" * w for w in col_widths)
32
+ print(header_row)
33
+ print(separator)
34
+
35
+ for row in rows:
36
+ print(" | ".join(str(cell).ljust(w)[:w] for cell, w in zip(row, col_widths, strict=False)))
37
+
38
+
39
+ def print_pagination_info(page_number: int, page_size: int, total_items: int) -> None:
40
+ """Print pagination information.
41
+
42
+ Parameters
43
+ ----------
44
+ page_number : int
45
+ Current page number.
46
+ page_size : int
47
+ Number of items per page.
48
+ total_items : int
49
+ Total number of items.
50
+ """
51
+ total_pages = (total_items + page_size - 1) // page_size
52
+ start_item = (page_number - 1) * page_size + 1
53
+ end_item = min(page_number * page_size, total_items)
54
+
55
+ print(
56
+ f"\nPage {page_number} of {total_pages} | Items {start_item}-{end_item} of {total_items}"
57
+ )
58
+ print("Navigation: [n]ext, [p]revious, [g]o to page, [q]uit")
59
+
60
+
61
+ def handle_pagination_input(current_page: int, total_pages: int) -> int | None:
62
+ """Handle pagination navigation input.
63
+
64
+ Returns
65
+ -------
66
+ int | None
67
+ The new page number, or None to exit.
68
+ """
69
+ try:
70
+ choice = input("\nEnter choice: ").strip().lower()
71
+ except (EOFError, KeyboardInterrupt):
72
+ print("\nExiting...")
73
+ return None
74
+
75
+ if choice == "q":
76
+ return None
77
+ if choice == "n" and current_page < total_pages:
78
+ return current_page + 1
79
+ if choice == "p" and current_page > 1:
80
+ return current_page - 1
81
+ if choice == "g":
82
+ try:
83
+ target = int(input("Go to page: ").strip())
84
+ if 1 <= target <= total_pages:
85
+ return target
86
+ print(f"Invalid page. Please enter 1-{total_pages}.")
87
+ except ValueError:
88
+ print("Invalid input. Please enter a number.")
89
+ elif choice in ("n", "p"):
90
+ print("Already at the first/last page.")
91
+ return current_page
@@ -321,7 +321,7 @@ def _format_version_info(version_data: dict | None) -> str:
321
321
  return "unknown"
322
322
 
323
323
  parts = []
324
- for repo in ["qt-models", "qt-calphad", "qt-base", "qt-optimizer"]:
324
+ for repo in ["qt-models", "qt-calphad", "qt-base", "qt-optimizer", "qt-designer"]:
325
325
  if repo in version_data and version_data[repo] != "unknown":
326
326
  version = version_data[repo]
327
327
  parts.append(f"{repo}@{version}")
@@ -354,7 +354,11 @@ def ensure_slim_packages(icmd_client: "ICMD") -> None:
354
354
  # Get remote version and download URL from backend (single API call)
355
355
  remote_version, download_url = get_slim_packages_info(icmd_client)
356
356
  if remote_version is None:
357
- # Specific error already printed by get_slim_packages_info()
357
+ # Backend unavailable - print warning and try cached version
358
+ print(
359
+ "⚠ Warning: Could not check slim packages version from backend. "
360
+ "Using cached version if available."
361
+ )
358
362
  # Try to inject cached version for this server
359
363
  server_dir = QTPY_SLIM_BASE / get_server_slug(icmd_client.domain)
360
364
  if server_dir.exists():
icmd/core/urls.py ADDED
@@ -0,0 +1,111 @@
1
+ """URL builders for ICMD API endpoints.
2
+
3
+ This module provides helper functions to construct API endpoint URLs,
4
+ so users don't need to know the string patterns.
5
+
6
+ Example usage:
7
+ from icmd import ICMD
8
+ from icmd.core import urls
9
+
10
+ client = ICMD("icmd.questek.com")
11
+ dataset = client.get(urls.dataset("uuid-123")).json()
12
+ client.upload(urls.datasets(), data=payload)
13
+ """
14
+
15
+ from .constants import (
16
+ DATASETS,
17
+ DESIGN,
18
+ DESIGN_TOOLKIT,
19
+ FATIGUE_TOOLKIT,
20
+ GROUPS,
21
+ JOB,
22
+ MACHINE_LEARNING_MODELS,
23
+ QT_MODEL,
24
+ RESULTS,
25
+ )
26
+
27
+
28
+ def _toolkit(toolkit: str = "design") -> str:
29
+ """Return toolkit endpoint.
30
+
31
+ Parameters
32
+ ----------
33
+ toolkit : str
34
+ Either "design" for Design Alloy or "fatigue" for Fatigue Simulation.
35
+
36
+ Returns
37
+ -------
38
+ str
39
+ The API endpoint string for the toolkit.
40
+
41
+ Raises
42
+ ------
43
+ ValueError
44
+ If toolkit is not "design" or "fatigue".
45
+ """
46
+ if toolkit == "design":
47
+ return DESIGN_TOOLKIT
48
+ if toolkit == "fatigue":
49
+ return FATIGUE_TOOLKIT
50
+ raise ValueError(f"Unknown toolkit: {toolkit}. Must be 'design' or 'fatigue'.")
51
+
52
+
53
+ # Dataset URLs
54
+ def datasets() -> str:
55
+ """Return the datasets endpoint."""
56
+ return DATASETS
57
+
58
+
59
+ def dataset(uuid: str) -> str:
60
+ """Return the endpoint for a specific dataset."""
61
+ return f"{DATASETS}/{uuid}"
62
+
63
+
64
+ # Design URLs
65
+ def designs(toolkit: str = "design") -> str:
66
+ """Return the designs endpoint for a toolkit."""
67
+ return f"{_toolkit(toolkit)}/{DESIGN}"
68
+
69
+
70
+ def design(uuid: str, toolkit: str = "design") -> str:
71
+ """Return the endpoint for a specific design."""
72
+ return f"{_toolkit(toolkit)}/{DESIGN}/{uuid}"
73
+
74
+
75
+ # Job URLs
76
+ def jobs(toolkit: str = "design") -> str:
77
+ """Return the jobs endpoint for a toolkit."""
78
+ return f"{_toolkit(toolkit)}/{JOB}"
79
+
80
+
81
+ def job(uuid: str, toolkit: str = "design") -> str:
82
+ """Return the endpoint for a specific job."""
83
+ return f"{_toolkit(toolkit)}/{JOB}/{uuid}"
84
+
85
+
86
+ # Results URLs
87
+ def results(uuid: str, toolkit: str = "design") -> str:
88
+ """Return the endpoint for specific results."""
89
+ return f"{_toolkit(toolkit)}/{RESULTS}/{uuid}"
90
+
91
+
92
+ # Model URLs
93
+ def models(toolkit: str = "design") -> str:
94
+ """Return the models endpoint for a toolkit."""
95
+ return f"{_toolkit(toolkit)}/{QT_MODEL}"
96
+
97
+
98
+ def ml_models() -> str:
99
+ """Return the ML models endpoint."""
100
+ return MACHINE_LEARNING_MODELS
101
+
102
+
103
+ def ml_model(uuid: str) -> str:
104
+ """Return the endpoint for a specific ML model."""
105
+ return f"{MACHINE_LEARNING_MODELS}/{uuid}"
106
+
107
+
108
+ # Groups
109
+ def groups() -> str:
110
+ """Return the groups endpoint."""
111
+ return GROUPS
icmd/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "1.5.7"
1
+ __version__ = "1.6.5"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icmd-python
3
- Version: 1.5.7
3
+ Version: 1.6.5
4
4
  Summary: Python client app for ICMD®
5
5
  Author: QuesTek Innovations LLC
6
6
  Requires-Python: >=3.12
@@ -13,10 +13,9 @@ Requires-Dist: requests
13
13
  Requires-Dist: scipy
14
14
  Requires-Dist: xarray
15
15
  Provides-Extra: dev
16
- Requires-Dist: myst-nb; extra == "dev"
16
+ Requires-Dist: pyright; extra == "dev"
17
17
  Requires-Dist: pytest; extra == "dev"
18
18
  Requires-Dist: pytest-xdist; extra == "dev"
19
19
  Requires-Dist: ruff; extra == "dev"
20
- Requires-Dist: sphinx; extra == "dev"
21
- Requires-Dist: sphinx-rtd-theme; extra == "dev"
22
20
  Requires-Dist: pre-commit; extra == "dev"
21
+ Requires-Dist: plotly; extra == "dev"
@@ -0,0 +1,15 @@
1
+ icmd/__init__.py,sha256=qRlXs1lxfZ7zwpE8OhUYFUatAt6kztvCHK8Mc8qhQIk,310
2
+ icmd/version.py,sha256=LEtHSENWZLwKaCJasxjOIQSt5Y_OahmKZYacXCmyQtI,22
3
+ icmd/core/__init__.py,sha256=p92vCWcT0SMFNZRrIBz57NreZyNEKTJRS-tE3QbbBvI,614
4
+ icmd/core/apps.py,sha256=iWbK63Nv6MlyYOxIcu_HvenGI6nokzoUTCxU6xouxJk,5086
5
+ icmd/core/client.py,sha256=Ag7Se48Z6r28hcFmhA0DtQbesn0_uWcRiZtfoAzGxxo,38460
6
+ icmd/core/constants.py,sha256=DG8u7PKLiihCQOR_uftMmgZh5CLh3PRqVl2KF4atdoY,364
7
+ icmd/core/credentials.py,sha256=PwLstCLl11KlusLVE7oBkU1rEgvb9u0HpWUhfapfBQY,6690
8
+ icmd/core/display.py,sha256=F89Ihcr4mtEZJkV3_J7LkOPbF-vsTQIvJZ1vSuZOTtY,4265
9
+ icmd/core/pagination.py,sha256=Xv9vTY02FFIGBvndQsXoz1ruhlu148vtCWWFbL_5fwI,2735
10
+ icmd/core/slim_packages.py,sha256=MO4cMF5yGrYTfqUQnNWt16ajxneY-pzDJTejTNwC0JE,16081
11
+ icmd/core/urls.py,sha256=-K7rsGKLOqpcVKJhQnZJJArJWEd7y_uw-vwaJyryVXc,2631
12
+ icmd_python-1.6.5.dist-info/METADATA,sha256=GswTuSpG6uFnhvx1V6RBSlfI2pYsSshisCOiwf5QlnY,591
13
+ icmd_python-1.6.5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
+ icmd_python-1.6.5.dist-info/top_level.txt,sha256=olzM6Z_QEwg7RrFVOnkgO0I2Ps2m7vhSqYQZYeiawcw,5
15
+ icmd_python-1.6.5.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (82.0.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,10 +0,0 @@
1
- icmd/__init__.py,sha256=Y3zT6WXeWkIuMsLjwXs1lTjiGSj-dzLvetVSmRu_u2w,84
2
- icmd/version.py,sha256=oF7Y8496FUgsdoGzNFWSpl1UtgtgO43P0Tct09Fxbx0,22
3
- icmd/core/__init__.py,sha256=4-zYFBsm52YklOAfbd0EF6wqAQS2bCIDeRAQFJRX9Lw,305
4
- icmd/core/client.py,sha256=_5cQsWCraJsZoQH6-iTdnS6LwbA2cPvPb4FTQg4Sx0o,31766
5
- icmd/core/credentials.py,sha256=PwLstCLl11KlusLVE7oBkU1rEgvb9u0HpWUhfapfBQY,6690
6
- icmd/core/slim_packages.py,sha256=EdZTv8kMuqHKuHeNCbzdREkgvJZr_JnWcR1ytfbCYXU,15896
7
- icmd_python-1.5.7.dist-info/METADATA,sha256=jj6l1A_bJXTuz09PjZ73hjG95zoEd0HnmC1gFJE1h2Q,639
8
- icmd_python-1.5.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
- icmd_python-1.5.7.dist-info/top_level.txt,sha256=olzM6Z_QEwg7RrFVOnkgO0I2Ps2m7vhSqYQZYeiawcw,5
10
- icmd_python-1.5.7.dist-info/RECORD,,