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 +13 -3
- icmd/apps/__init__.py +18 -0
- icmd/apps/base.py +254 -0
- icmd/apps/design.py +35 -0
- icmd/apps/fatigue.py +33 -0
- icmd/apps/types.py +39 -0
- icmd/apps/workspace.py +41 -0
- icmd/cli/__init__.py +177 -0
- icmd/cli/auth.py +88 -0
- icmd/cli/client.py +39 -0
- icmd/cli/commands.py +295 -0
- icmd/cli/domain.py +23 -0
- icmd/cli/output.py +28 -0
- icmd/cli/registry.py +118 -0
- icmd/client.py +229 -0
- icmd/core/__init__.py +5 -11
- icmd/core/auth.py +679 -0
- icmd/core/constants.py +42 -0
- icmd/core/credentials.py +11 -2
- icmd/core/display.py +338 -0
- icmd/core/exceptions.py +55 -0
- icmd/core/groups.py +187 -0
- icmd/core/session.py +464 -0
- icmd/core/slim_packages.py +308 -376
- icmd/version.py +1 -1
- {icmd_python-1.5.8.dist-info → icmd_python-1.7.1.dist-info}/METADATA +4 -5
- icmd_python-1.7.1.dist-info/RECORD +30 -0
- {icmd_python-1.5.8.dist-info → icmd_python-1.7.1.dist-info}/WHEEL +1 -1
- icmd_python-1.7.1.dist-info/entry_points.txt +2 -0
- icmd/core/client.py +0 -815
- icmd_python-1.5.8.dist-info/RECORD +0 -10
- {icmd_python-1.5.8.dist-info → icmd_python-1.7.1.dist-info}/top_level.txt +0 -0
icmd/__init__.py
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
"""Python client
|
|
1
|
+
"""Python client for the ICMD® API."""
|
|
2
2
|
|
|
3
|
-
from .
|
|
3
|
+
from .client import ICMD
|
|
4
|
+
from .core.exceptions import (
|
|
5
|
+
ICMDAuthenticationError,
|
|
6
|
+
ICMDConnectionError,
|
|
7
|
+
ICMDValidationError,
|
|
8
|
+
)
|
|
4
9
|
|
|
5
|
-
__all__ = [
|
|
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()
|