icmd-python 1.5.8__py3-none-any.whl → 1.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
icmd/__init__.py CHANGED
@@ -1,5 +1,15 @@
1
- """Python client app for ICMD® API."""
1
+ """Python client for the ICMD® API."""
2
2
 
3
- from .core import ICMD
3
+ from .client import ICMD
4
+ from .core.exceptions import (
5
+ ICMDAuthenticationError,
6
+ ICMDConnectionError,
7
+ ICMDValidationError,
8
+ )
4
9
 
5
- __all__ = ["ICMD"]
10
+ __all__ = [
11
+ "ICMD",
12
+ "ICMDAuthenticationError",
13
+ "ICMDConnectionError",
14
+ "ICMDValidationError",
15
+ ]
icmd/apps/__init__.py ADDED
@@ -0,0 +1,18 @@
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.alloy_design.job.list(page=1, page_size=10)
7
+ icmd.workspace.dataset.get("uuid-123")
8
+ """
9
+
10
+ from .design import DesignApp
11
+ from .fatigue import FatigueApp
12
+ from .workspace import WorkspaceApp
13
+
14
+ __all__ = [
15
+ "DesignApp",
16
+ "FatigueApp",
17
+ "WorkspaceApp",
18
+ ]
icmd/apps/base.py ADDED
@@ -0,0 +1,254 @@
1
+ """Base resource classes for ICMD® API access.
2
+
3
+ Provides generic CRUD resources and specialized subclasses for designs
4
+ and jobs. App modules compose these to expose a clean nested-attribute
5
+ API through the :class:`~icmd.client.ICMD` facade.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any, Literal
11
+
12
+ from icmd.core.exceptions import ICMDRequestError
13
+
14
+ if TYPE_CHECKING:
15
+ from icmd.core.session import ResourceClient
16
+
17
+ from .types import JobLogResponse, PaginatedResponse
18
+
19
+
20
+ def _expect_dict(data: dict | list[dict], endpoint: str) -> dict:
21
+ """Narrow a transport-layer response to a single object dict."""
22
+ if isinstance(data, dict):
23
+ return data
24
+ raise ICMDRequestError(f"Expected object response from '{endpoint}', got list")
25
+
26
+
27
+ _DEFAULT_PAGE_SIZE = 10
28
+
29
+
30
+ def _expect_paginated(
31
+ data: dict | list[dict],
32
+ endpoint: str,
33
+ params: dict[str, Any] | None = None,
34
+ ) -> PaginatedResponse:
35
+ """Narrow a transport-layer response to a paginated envelope.
36
+
37
+ Some endpoints (e.g. ``qt_model``) return a plain list instead of a
38
+ paginated envelope. When that happens the list is client-side
39
+ paginated using ``page`` and ``page_size`` from *params* (defaulting
40
+ to page 1 / 10 items) so callers can treat every list response
41
+ uniformly.
42
+ """
43
+ if isinstance(data, list):
44
+ total = len(data)
45
+ return { # type: ignore[return-value]
46
+ "count": total,
47
+ "page_count": 1,
48
+ "page": 1,
49
+ "page_size": total or _DEFAULT_PAGE_SIZE,
50
+ "next": None,
51
+ "previous": None,
52
+ "data": data,
53
+ }
54
+ if isinstance(data, dict) and "data" in data and "count" in data:
55
+ return data # type: ignore[return-value]
56
+ raise ICMDRequestError(
57
+ f"Expected paginated response from '{endpoint}', got {type(data).__name__}"
58
+ )
59
+
60
+
61
+ class Resource:
62
+ """Generic CRUD resource bound to an API path.
63
+
64
+ Parameters
65
+ ----------
66
+ client : ResourceClient
67
+ Any object satisfying the :class:`ResourceClient` protocol
68
+ (i.e. :class:`~icmd.client.ICMD`).
69
+ path : str
70
+ The API endpoint path (e.g. ``"design_alloy/job"``).
71
+ """
72
+
73
+ def __init__(self, client: ResourceClient, path: str) -> None:
74
+ self._client = client
75
+ self._path = path
76
+
77
+ def list(
78
+ self,
79
+ *,
80
+ display: Literal["auto", "off"] = "off",
81
+ **params: Any,
82
+ ) -> PaginatedResponse:
83
+ """List resources with pagination.
84
+
85
+ Parameters
86
+ ----------
87
+ display : ``"auto"`` | ``"off"``
88
+ ``"auto"`` renders an interactive paginated table after fetching
89
+ (ipywidgets in Jupyter, text table in terminal). ``"off"``
90
+ returns data with no side effects.
91
+ **params
92
+ Query parameters forwarded to the API (e.g. ``page``, ``page_size``).
93
+ """
94
+ raw = self._client.request_json("get", self._path, params=params or None)
95
+ data = _expect_paginated(raw, self._path, params or None)
96
+ if display == "auto":
97
+ from icmd.core.display import display as _display
98
+
99
+ _display(
100
+ self._client,
101
+ self._path,
102
+ dict(data),
103
+ params or None,
104
+ local_pagination=isinstance(raw, list),
105
+ )
106
+ return data
107
+
108
+ def get(self, uuid: str) -> dict:
109
+ """Get a single resource by UUID.
110
+
111
+ Group injection is skipped so the backend resolves access across
112
+ all groups the caller belongs to (cross-group retrieval).
113
+ """
114
+ endpoint = f"{self._path}/{uuid}"
115
+ return _expect_dict(
116
+ self._client.request_json("get", endpoint, inject_group=False), endpoint
117
+ )
118
+
119
+ def add(self, data: Any = None) -> dict:
120
+ """Create a new resource."""
121
+ return _expect_dict(
122
+ self._client.request_json("post", self._path, json=data),
123
+ self._path,
124
+ )
125
+
126
+
127
+ _TERMINAL_STATUSES = frozenset({"successful", "failed", "partial", "cancelled"})
128
+
129
+
130
+ class JobResource(Resource):
131
+ """Job resource with log retrieval and status polling."""
132
+
133
+ def check_status(self, uuid: str) -> tuple[str, dict]:
134
+ """Check the current status of a job.
135
+
136
+ Returns (status, job_data) where status is the job_status string.
137
+ """
138
+ job_data = self.get(uuid)
139
+ status = job_data.get("job_status", "unknown")
140
+ return status, job_data
141
+
142
+ def poll(
143
+ self,
144
+ uuid: str,
145
+ *,
146
+ interval: int = 5,
147
+ quiet: bool = False,
148
+ logs: bool = True,
149
+ ):
150
+ """Poll until the job reaches a terminal status, streaming logs.
151
+
152
+ Parameters
153
+ ----------
154
+ uuid : str
155
+ The job UUID.
156
+ interval : int
157
+ Seconds between poll attempts (default 5).
158
+ quiet : bool
159
+ When True, suppress all printed output and skip log fetching.
160
+ logs : bool
161
+ When True (default), fetch and print job logs each poll cycle.
162
+ When False, only print status transitions. Ignored if *quiet* is True.
163
+ """
164
+ import time
165
+
166
+ last_status = None
167
+ next_ts: str | None = None
168
+
169
+ try:
170
+ while True:
171
+ job_data = self.get(uuid)
172
+ status = job_data.get("job_status", "unknown").lower()
173
+
174
+ if not quiet and status != last_status:
175
+ print(f"Job {uuid}: {status}")
176
+ last_status = status
177
+
178
+ if not quiet and logs:
179
+ log_resp = self.log(uuid, from_timestamp=next_ts)
180
+ log_text = log_resp.get("logs", "")
181
+ if log_text:
182
+ print(log_text, end="")
183
+ next_ts = log_resp.get("next_timestamp")
184
+
185
+ if status in _TERMINAL_STATUSES:
186
+ return
187
+
188
+ time.sleep(interval)
189
+ except KeyboardInterrupt:
190
+ if not quiet:
191
+ print(f"\nPolling interrupted. Last status: {last_status}")
192
+ return
193
+
194
+ def log(
195
+ self,
196
+ uuid: str,
197
+ *,
198
+ from_timestamp: str | None = None,
199
+ ) -> JobLogResponse:
200
+ """Retrieve logs for a job.
201
+
202
+ Parameters
203
+ ----------
204
+ uuid : str
205
+ The job UUID.
206
+ from_timestamp : str | None
207
+ ISO 8601 timestamp for incremental log fetching.
208
+ When provided, only logs created after this timestamp are returned.
209
+
210
+ Returns
211
+ -------
212
+ JobLogResponse
213
+ Dict with ``logs``, ``next_timestamp``, ``logs_finished``,
214
+ and ``is_full_log`` keys.
215
+ """
216
+ endpoint = f"{self._path}/{uuid}/get_job_log"
217
+ params: dict[str, str] = {}
218
+ if from_timestamp is not None:
219
+ params["from_timestamp"] = from_timestamp
220
+ return _expect_dict( # type: ignore[return-value]
221
+ self._client.request_json("get", endpoint, inject_group=False, params=params or None),
222
+ endpoint,
223
+ )
224
+
225
+
226
+ class DesignResource(Resource):
227
+ """Design resource with create-time metadata."""
228
+
229
+ def add(
230
+ self,
231
+ data: dict | None = None,
232
+ *,
233
+ design_name: str | None = None,
234
+ share_with_group: bool = True,
235
+ ) -> dict:
236
+ """Create a new design.
237
+
238
+ Parameters
239
+ ----------
240
+ data : dict | None
241
+ Payload for the design. If None, an empty dict is used.
242
+ design_name : str | None
243
+ Optional name for the design (sets 'name' key).
244
+ share_with_group : bool
245
+ Whether to share with the owner group (default True).
246
+ """
247
+ payload: dict[str, Any] = dict(data) if data else {}
248
+ if design_name is not None:
249
+ payload["name"] = design_name
250
+ payload["share_with_group"] = share_with_group
251
+ return _expect_dict(
252
+ self._client.request_json("post", self._path, json=payload),
253
+ self._path,
254
+ )
icmd/apps/design.py ADDED
@@ -0,0 +1,35 @@
1
+ """Design Alloy toolkit resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .base import DesignResource, JobResource, Resource
8
+
9
+ if TYPE_CHECKING:
10
+ from icmd.core.session import ResourceClient
11
+
12
+
13
+ class DesignApp:
14
+ """Design Alloy toolkit resources."""
15
+
16
+ __slots__ = (
17
+ "design",
18
+ "folder",
19
+ "job",
20
+ "material_family",
21
+ "model",
22
+ "model_workflow",
23
+ "result",
24
+ "settings",
25
+ )
26
+
27
+ def __init__(self, client: ResourceClient) -> None:
28
+ self.job = JobResource(client, "design_alloy/job")
29
+ self.design = DesignResource(client, "design_alloy/design")
30
+ self.model = Resource(client, "design_alloy/qt_model")
31
+ self.result = Resource(client, "design_alloy/results")
32
+ self.model_workflow = Resource(client, "design_alloy/model_workflow")
33
+ self.settings = Resource(client, "design_alloy/settings")
34
+ self.folder = Resource(client, "design_alloy/folders")
35
+ self.material_family = Resource(client, "design_alloy/material_family")
icmd/apps/fatigue.py ADDED
@@ -0,0 +1,33 @@
1
+ """Fatigue Simulation toolkit resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .base import DesignResource, JobResource, Resource
8
+
9
+ if TYPE_CHECKING:
10
+ from icmd.core.session import ResourceClient
11
+
12
+
13
+ class FatigueApp:
14
+ """Fatigue Simulation toolkit resources."""
15
+
16
+ __slots__ = (
17
+ "design",
18
+ "folder",
19
+ "job",
20
+ "material_family",
21
+ "model",
22
+ "model_workflow",
23
+ "result",
24
+ )
25
+
26
+ def __init__(self, client: ResourceClient) -> None:
27
+ self.job = JobResource(client, "simulation_fatigue/job")
28
+ self.design = DesignResource(client, "simulation_fatigue/design")
29
+ self.model = Resource(client, "simulation_fatigue/qt_model")
30
+ self.result = Resource(client, "simulation_fatigue/results")
31
+ self.model_workflow = Resource(client, "simulation_fatigue/model_workflow")
32
+ self.folder = Resource(client, "simulation_fatigue/folders")
33
+ self.material_family = Resource(client, "simulation_fatigue/material_family")
icmd/apps/types.py ADDED
@@ -0,0 +1,39 @@
1
+ """Response types for ICMD® app resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypedDict
6
+
7
+
8
+ class PaginatedResponse(TypedDict):
9
+ """Pagination envelope returned by all list endpoints.
10
+
11
+ Mirrors the backend's ``StandardPagination.get_paginated_response()``.
12
+ The ``data`` key contains the page items; ``count``, ``page_count``,
13
+ ``page``, and ``page_size`` describe pagination metadata.
14
+ """
15
+
16
+ count: int
17
+ page_count: int
18
+ page: int
19
+ page_size: int
20
+ next: str | None
21
+ previous: str | None
22
+ data: list[dict]
23
+
24
+
25
+ class JobLogResponse(TypedDict):
26
+ """Response from the job log endpoint.
27
+
28
+ ``logs`` contains the log text (may be a partial chunk when using
29
+ ``from_timestamp`` for incremental fetching). ``next_timestamp``
30
+ is the cursor for the next incremental request (``None`` when no
31
+ more logs are available). ``logs_finished`` indicates whether the
32
+ job has completed logging. ``is_full_log`` indicates whether the
33
+ response contains the complete combined log.
34
+ """
35
+
36
+ logs: str
37
+ next_timestamp: str | None
38
+ logs_finished: bool
39
+ is_full_log: bool
icmd/apps/workspace.py ADDED
@@ -0,0 +1,41 @@
1
+ """Workspace resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .base import Resource
8
+
9
+ if TYPE_CHECKING:
10
+ from icmd.core.session import ResourceClient
11
+
12
+
13
+ class WorkspaceApp:
14
+ """Workspace resources."""
15
+
16
+ __slots__ = (
17
+ "composition",
18
+ "composition_group",
19
+ "database",
20
+ "dataset",
21
+ "document",
22
+ "document_folder",
23
+ "ml_model",
24
+ "plot",
25
+ "project",
26
+ "specification",
27
+ "specification_group",
28
+ )
29
+
30
+ def __init__(self, client: ResourceClient) -> None:
31
+ self.dataset = Resource(client, "workspace/dataset")
32
+ self.ml_model = Resource(client, "workspace/mlmodels")
33
+ self.project = Resource(client, "workspace/project")
34
+ self.database = Resource(client, "workspace/database")
35
+ self.document = Resource(client, "workspace/document")
36
+ self.document_folder = Resource(client, "workspace/document_folder")
37
+ self.composition = Resource(client, "workspace/composition")
38
+ self.composition_group = Resource(client, "workspace/composition_group")
39
+ self.specification = Resource(client, "workspace/specification")
40
+ self.specification_group = Resource(client, "workspace/specification_group")
41
+ self.plot = Resource(client, "workspace/plot")
icmd/cli/__init__.py ADDED
@@ -0,0 +1,177 @@
1
+ """ICMD® command-line interface.
2
+
3
+ Entry point: ``icmd`` (installed via ``[project.scripts]``).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+ from icmd.client import ICMD
13
+ from icmd.core.credentials import CredentialManager
14
+ from icmd.core.exceptions import (
15
+ ICMDAuthenticationError,
16
+ ICMDConnectionError,
17
+ ICMDRequestError,
18
+ ICMDValidationError,
19
+ )
20
+ from icmd.core.session import normalize_domain
21
+ from icmd.version import __version__
22
+
23
+ from .auth import CLIAuthenticator, NullAuthenticator
24
+ from .commands import _global_options, register_commands
25
+ from .domain import expand_domain
26
+ from .output import write_error, write_output
27
+
28
+
29
+ class _ICMDGroup(click.Group):
30
+ """Root group with ICMD® error handling."""
31
+
32
+ def invoke(self, ctx: click.Context) -> Any:
33
+ """Invoke the command, catching SDK exceptions as JSON errors."""
34
+ try:
35
+ return super().invoke(ctx)
36
+ except click.exceptions.Exit:
37
+ raise
38
+ except click.ClickException:
39
+ raise
40
+ except (
41
+ ICMDAuthenticationError,
42
+ ICMDValidationError,
43
+ ICMDConnectionError,
44
+ ICMDRequestError,
45
+ ) as exc:
46
+ name = type(exc).__name__.removeprefix("ICMD")
47
+ write_error(name, str(exc))
48
+ ctx.exit(1)
49
+ except KeyboardInterrupt:
50
+ ctx.exit(130)
51
+ return None
52
+
53
+
54
+ @click.group(cls=_ICMDGroup)
55
+ @click.version_option(__version__, prog_name="icmd")
56
+ @click.option(
57
+ "--domain",
58
+ envvar="ICMD_DOMAIN",
59
+ default=None,
60
+ help="ICMD® domain (e.g. 'dev' or 'dev.icmd.questek.com').",
61
+ )
62
+ @click.option(
63
+ "--token",
64
+ envvar="ICMD_TOKEN",
65
+ default=None,
66
+ help="Developer API token (alternative to 'icmd login').",
67
+ )
68
+ @click.option(
69
+ "--group",
70
+ envvar="ICMD_GROUP",
71
+ default=None,
72
+ help="Owner group ID or name.",
73
+ )
74
+ @click.option(
75
+ "--format",
76
+ "output_format",
77
+ type=click.Choice(["json", "text"]),
78
+ default="json",
79
+ show_default=True,
80
+ help="Output format.",
81
+ )
82
+ @click.pass_context
83
+ def cli(
84
+ ctx: click.Context,
85
+ domain: str | None,
86
+ token: str | None,
87
+ group: str | None,
88
+ output_format: str,
89
+ ) -> None:
90
+ """ICMD® command-line interface."""
91
+ ctx.ensure_object(dict)
92
+ ctx.obj["domain"] = expand_domain(domain) if domain else domain
93
+ ctx.obj["token"] = token
94
+ ctx.obj["group"] = group
95
+ ctx.obj["format"] = output_format
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Top-level commands
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ @cli.command()
104
+ @click.option(
105
+ "--domain",
106
+ envvar="ICMD_DOMAIN",
107
+ required=True,
108
+ help="ICMD® domain (e.g. 'dev' or 'dev.icmd.questek.com').",
109
+ )
110
+ @click.pass_context
111
+ def login(ctx: click.Context, domain: str) -> None:
112
+ """Authenticate with an ICMD® instance.
113
+
114
+ Triggers interactive authentication (SAML or password) and caches
115
+ credentials for subsequent commands.
116
+ """
117
+ domain = expand_domain(domain)
118
+ ICMD(domain, authenticator=CLIAuthenticator(), skip_slim=True)
119
+ fmt = ctx.obj.get("format", "json")
120
+ write_output({"status": "ok", "domain": domain}, fmt=fmt)
121
+
122
+
123
+ @cli.command()
124
+ @click.option(
125
+ "--domain",
126
+ envvar="ICMD_DOMAIN",
127
+ required=True,
128
+ help="ICMD® domain (e.g. 'dev' or 'dev.icmd.questek.com').",
129
+ )
130
+ @click.pass_context
131
+ def logout(ctx: click.Context, domain: str) -> None:
132
+ """Clear cached credentials for an ICMD® instance."""
133
+ domain = expand_domain(domain)
134
+ normalized = normalize_domain(domain)
135
+ CredentialManager().clear_domain_credentials(normalized)
136
+ fmt = ctx.obj.get("format", "json")
137
+ write_output({"status": "ok", "domain": normalized}, fmt=fmt)
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Top-level account commands (no owner-group injection)
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ @cli.group("group")
146
+ def group_cmd() -> None:
147
+ """Manage user groups."""
148
+
149
+
150
+ @group_cmd.command("list")
151
+ @_global_options
152
+ def group_list() -> None:
153
+ """List groups the current user belongs to."""
154
+ ctx = click.get_current_context()
155
+ domain = ctx.obj.get("domain")
156
+ if not domain:
157
+ raise click.UsageError("Missing --domain option or ICMD_DOMAIN environment variable.")
158
+ client = ICMD(
159
+ domain=domain,
160
+ developer_token=ctx.obj.get("token"),
161
+ authenticator=NullAuthenticator(),
162
+ skip_slim=True,
163
+ )
164
+ groups = client.get_groups()
165
+ write_output(groups, fmt=ctx.obj.get("format", "json"))
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Register resource commands from the registry
170
+ # ---------------------------------------------------------------------------
171
+
172
+ register_commands(cli)
173
+
174
+
175
+ def main() -> None:
176
+ """CLI entry point."""
177
+ cli()