icmd-python 3.10.0__py3-none-any.whl → 3.14.0__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 +2 -0
- icmd/_vendor/assembler.py +10 -2
- icmd/apps/__init__.py +2 -0
- icmd/apps/agent.py +5 -1
- icmd/apps/base.py +41 -21
- icmd/apps/comments.py +34 -0
- icmd/apps/jobs.py +17 -7
- icmd/apps/results.py +3 -1
- icmd/cli/__init__.py +2 -0
- icmd/cli/commands.py +59 -27
- icmd/cli/registry.py +24 -0
- icmd/client.py +75 -4
- icmd/compose.py +9 -3
- icmd/core/advisory.py +115 -21
- icmd/core/auth.py +3 -6
- icmd/core/constants.py +9 -0
- icmd/core/contracts.py +45 -3
- icmd/core/exceptions.py +9 -0
- icmd/core/groups.py +13 -6
- icmd/core/session.py +5 -0
- icmd/version.py +1 -1
- {icmd_python-3.10.0.dist-info → icmd_python-3.14.0.dist-info}/METADATA +3 -3
- {icmd_python-3.10.0.dist-info → icmd_python-3.14.0.dist-info}/RECORD +27 -26
- {icmd_python-3.10.0.dist-info → icmd_python-3.14.0.dist-info}/WHEEL +1 -1
- {icmd_python-3.10.0.dist-info → icmd_python-3.14.0.dist-info}/entry_points.txt +0 -0
- {icmd_python-3.10.0.dist-info → icmd_python-3.14.0.dist-info}/licenses/LICENSE +0 -0
- {icmd_python-3.10.0.dist-info → icmd_python-3.14.0.dist-info}/top_level.txt +0 -0
icmd/__init__.py
CHANGED
|
@@ -22,6 +22,7 @@ from .core.exceptions import (
|
|
|
22
22
|
ICMDResultsRefusedError,
|
|
23
23
|
ICMDTokenRefreshError,
|
|
24
24
|
ICMDValidationError,
|
|
25
|
+
ICMDVersionError,
|
|
25
26
|
)
|
|
26
27
|
from .core.model_classes import ContractFallbackWarning
|
|
27
28
|
from .links import KIND_PATHS, link_for
|
|
@@ -105,6 +106,7 @@ __all__ = [
|
|
|
105
106
|
"ICMDResultsRefusedError",
|
|
106
107
|
"ICMDTokenRefreshError",
|
|
107
108
|
"ICMDValidationError",
|
|
109
|
+
"ICMDVersionError",
|
|
108
110
|
"LatinHypercubeSampling",
|
|
109
111
|
"ModelInput",
|
|
110
112
|
"ModelNode",
|
icmd/_vendor/assembler.py
CHANGED
|
@@ -113,7 +113,7 @@ def assemble_model_node(
|
|
|
113
113
|
|
|
114
114
|
|
|
115
115
|
def build_design_document(
|
|
116
|
-
composition: Mapping[str, Any],
|
|
116
|
+
composition: Mapping[str, Any] | None,
|
|
117
117
|
variables: Mapping[str, Any] | None = None,
|
|
118
118
|
databases: Sequence[Mapping[str, Any]] | None = None,
|
|
119
119
|
engine: str | None = None,
|
|
@@ -126,6 +126,12 @@ def build_design_document(
|
|
|
126
126
|
all, so an empty one is left out rather than written present-and-falsy.
|
|
127
127
|
Passing ``[]`` or ``""`` is therefore the same as passing nothing, and the
|
|
128
128
|
advisory reports the deferring model either way.
|
|
129
|
+
|
|
130
|
+
``composition`` follows the same rule, for the "none" composition mode a
|
|
131
|
+
design uses when its models do not need thermodynamics. The key must be
|
|
132
|
+
absent rather than empty: an empty mapping reads downstream as a
|
|
133
|
+
composition that happens to have no components, which is a different claim.
|
|
134
|
+
The envelope schema does not require the key.
|
|
129
135
|
"""
|
|
130
136
|
design_space = deepcopy(dict(variables)) if variables is not None else {}
|
|
131
137
|
design_space.setdefault("sampling_input", {"type": "grid"})
|
|
@@ -133,7 +139,9 @@ def build_design_document(
|
|
|
133
139
|
design_space.setdefault("model_variables", [])
|
|
134
140
|
design_space.setdefault("dataset_variables", [])
|
|
135
141
|
design_space.setdefault("series_dataset_variables", [])
|
|
136
|
-
document: dict[str, Any] = {
|
|
142
|
+
document: dict[str, Any] = {}
|
|
143
|
+
if composition:
|
|
144
|
+
document["composition"] = deepcopy(dict(composition))
|
|
137
145
|
if databases:
|
|
138
146
|
document["databases"] = deepcopy(list(databases))
|
|
139
147
|
if engine:
|
icmd/apps/__init__.py
CHANGED
|
@@ -11,6 +11,7 @@ Provides a clean nested-attribute API::
|
|
|
11
11
|
|
|
12
12
|
from .account import AccountApp
|
|
13
13
|
from .agent import AgentApp
|
|
14
|
+
from .comments import CommentsApp
|
|
14
15
|
from .constants import ProjectKind
|
|
15
16
|
from .studio import StudioApp
|
|
16
17
|
from .suite import SuiteApp
|
|
@@ -19,6 +20,7 @@ from .workspace import WorkspaceApp
|
|
|
19
20
|
__all__ = [
|
|
20
21
|
"AccountApp",
|
|
21
22
|
"AgentApp",
|
|
23
|
+
"CommentsApp",
|
|
22
24
|
"ProjectKind",
|
|
23
25
|
"StudioApp",
|
|
24
26
|
"SuiteApp",
|
icmd/apps/agent.py
CHANGED
|
@@ -55,7 +55,11 @@ def _poll_message(
|
|
|
55
55
|
elapsed = 0
|
|
56
56
|
while True:
|
|
57
57
|
msg = _expect_dict(
|
|
58
|
-
client.request_json(
|
|
58
|
+
client.request_json(
|
|
59
|
+
"get",
|
|
60
|
+
f"{_MESSAGE_PATH}/{message_uuid}",
|
|
61
|
+
inject_group=client.has_owner_group,
|
|
62
|
+
),
|
|
59
63
|
_MESSAGE_PATH,
|
|
60
64
|
)
|
|
61
65
|
if (msg.get("status") or "").lower() in _TERMINAL_MESSAGE_STATUSES:
|
icmd/apps/base.py
CHANGED
|
@@ -214,7 +214,7 @@ class ReadOnlyResource:
|
|
|
214
214
|
uuid: str,
|
|
215
215
|
*,
|
|
216
216
|
params: dict[str, Any] | None = None,
|
|
217
|
-
inject_group: bool =
|
|
217
|
+
inject_group: bool | None = None,
|
|
218
218
|
) -> dict:
|
|
219
219
|
"""Get a single resource by UUID.
|
|
220
220
|
|
|
@@ -223,21 +223,26 @@ class ReadOnlyResource:
|
|
|
223
223
|
caller's server-side ``current_group`` (whatever they last
|
|
224
224
|
selected in the web UI). A resource owned by any other group
|
|
225
225
|
answers ``404`` — this is not a cross-group lookup, and omitting
|
|
226
|
-
``owner_group`` does not make it one.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
226
|
+
``owner_group`` does not make it one. By default the client's group
|
|
227
|
+
is sent when it already holds one (see :attr:`ICMD.has_owner_group`),
|
|
228
|
+
so a resource created through this client is found again. For jobs
|
|
229
|
+
specifically, the Studio action ``studio/jobs/{uuid}/accessible-group/``
|
|
230
|
+
answers which of your groups can ordinarily see a job; it walks plain
|
|
231
|
+
project access, so it performs no admin widening.
|
|
230
232
|
|
|
231
233
|
Parameters
|
|
232
234
|
----------
|
|
233
235
|
uuid : str
|
|
234
236
|
The resource UUID.
|
|
235
237
|
params : dict | None
|
|
236
|
-
Query parameters, forwarded only when not ``None``.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
``
|
|
240
|
-
|
|
238
|
+
Query parameters, forwarded only when not ``None``. An
|
|
239
|
+
``owner_group`` named here is sent as given.
|
|
240
|
+
inject_group : bool | None
|
|
241
|
+
``None`` (default) pins the lookup to the client's
|
|
242
|
+
``owner_group`` only when the client already holds one, and never
|
|
243
|
+
resolves or prompts for one. ``True`` always pins, resolving the
|
|
244
|
+
group if needed; ``False`` never pins, leaving the lookup to the
|
|
245
|
+
caller's ``current_group``.
|
|
241
246
|
|
|
242
247
|
Returns
|
|
243
248
|
-------
|
|
@@ -245,6 +250,8 @@ class ReadOnlyResource:
|
|
|
245
250
|
The resource record.
|
|
246
251
|
"""
|
|
247
252
|
endpoint = f"{self._path}/{uuid}"
|
|
253
|
+
if inject_group is None:
|
|
254
|
+
inject_group = self._client.has_owner_group
|
|
248
255
|
kwargs: dict[str, Any] = {"inject_group": inject_group}
|
|
249
256
|
if params is not None:
|
|
250
257
|
kwargs["params"] = params
|
|
@@ -268,8 +275,10 @@ class ReadOnlyResource:
|
|
|
268
275
|
*method*.
|
|
269
276
|
|
|
270
277
|
The active ``owner_group`` is injected as a query parameter for
|
|
271
|
-
*list* actions (no *uuid*)
|
|
272
|
-
same convention :meth:`list` and
|
|
278
|
+
*list* actions (no *uuid*); *detail* actions send it only when the
|
|
279
|
+
client already holds one — the same convention :meth:`list` and
|
|
280
|
+
:meth:`get` follow. An ``owner_group`` named in *params* is sent as
|
|
281
|
+
given either way.
|
|
273
282
|
|
|
274
283
|
Parameters
|
|
275
284
|
----------
|
|
@@ -292,7 +301,7 @@ class ReadOnlyResource:
|
|
|
292
301
|
list, some a single object).
|
|
293
302
|
"""
|
|
294
303
|
endpoint = "/".join(part for part in (self._path, uuid, name) if part)
|
|
295
|
-
kwargs: dict[str, Any] = {"inject_group": uuid is None}
|
|
304
|
+
kwargs: dict[str, Any] = {"inject_group": uuid is None or self._client.has_owner_group}
|
|
296
305
|
if data is not None:
|
|
297
306
|
kwargs["json"] = data
|
|
298
307
|
if params is not None:
|
|
@@ -328,8 +337,8 @@ class Resource(ReadOnlyResource):
|
|
|
328
337
|
supported across the API — several viewsets accept ``PATCH`` but
|
|
329
338
|
block ``PUT`` to keep their state-machine guards intact (e.g.
|
|
330
339
|
studio jobs, suite ML models). Use :meth:`replace` for a full
|
|
331
|
-
``PUT`` replacement where the resource allows it.
|
|
332
|
-
is
|
|
340
|
+
``PUT`` replacement where the resource allows it. Like :meth:`get`,
|
|
341
|
+
the request is pinned to the client's group only when it holds one.
|
|
333
342
|
|
|
334
343
|
Parameters
|
|
335
344
|
----------
|
|
@@ -345,7 +354,9 @@ class Resource(ReadOnlyResource):
|
|
|
345
354
|
"""
|
|
346
355
|
endpoint = f"{self._path}/{uuid}"
|
|
347
356
|
return _expect_dict(
|
|
348
|
-
self._client.request_json(
|
|
357
|
+
self._client.request_json(
|
|
358
|
+
"patch", endpoint, inject_group=self._client.has_owner_group, json=data
|
|
359
|
+
),
|
|
349
360
|
endpoint,
|
|
350
361
|
)
|
|
351
362
|
|
|
@@ -369,7 +380,9 @@ class Resource(ReadOnlyResource):
|
|
|
369
380
|
"""
|
|
370
381
|
endpoint = f"{self._path}/{uuid}"
|
|
371
382
|
return _expect_dict(
|
|
372
|
-
self._client.request_json(
|
|
383
|
+
self._client.request_json(
|
|
384
|
+
"put", endpoint, inject_group=self._client.has_owner_group, json=data
|
|
385
|
+
),
|
|
373
386
|
endpoint,
|
|
374
387
|
)
|
|
375
388
|
|
|
@@ -399,7 +412,9 @@ class Resource(ReadOnlyResource):
|
|
|
399
412
|
"""
|
|
400
413
|
endpoint = f"{self._path}/{uuid}"
|
|
401
414
|
return _expect_dict(
|
|
402
|
-
self._client.request_json(
|
|
415
|
+
self._client.request_json(
|
|
416
|
+
"delete", endpoint, inject_group=self._client.has_owner_group
|
|
417
|
+
),
|
|
403
418
|
endpoint,
|
|
404
419
|
)
|
|
405
420
|
|
|
@@ -435,7 +450,8 @@ class SoftDeleteMixin:
|
|
|
435
450
|
"""
|
|
436
451
|
endpoint = f"{self._path}/{uuid}/trash"
|
|
437
452
|
return _expect_dict(
|
|
438
|
-
self._client.request_json("post", endpoint, inject_group=
|
|
453
|
+
self._client.request_json("post", endpoint, inject_group=self._client.has_owner_group),
|
|
454
|
+
endpoint,
|
|
439
455
|
)
|
|
440
456
|
|
|
441
457
|
def restore(self, uuid: str) -> dict:
|
|
@@ -453,7 +469,8 @@ class SoftDeleteMixin:
|
|
|
453
469
|
"""
|
|
454
470
|
endpoint = f"{self._path}/{uuid}/restore"
|
|
455
471
|
return _expect_dict(
|
|
456
|
-
self._client.request_json("post", endpoint, inject_group=
|
|
472
|
+
self._client.request_json("post", endpoint, inject_group=self._client.has_owner_group),
|
|
473
|
+
endpoint,
|
|
457
474
|
)
|
|
458
475
|
|
|
459
476
|
def permanent_delete(self, uuid: str, *, confirm: bool = False) -> dict:
|
|
@@ -483,7 +500,10 @@ class SoftDeleteMixin:
|
|
|
483
500
|
)
|
|
484
501
|
endpoint = f"{self._path}/{uuid}/permanent-delete"
|
|
485
502
|
return _expect_dict(
|
|
486
|
-
self._client.request_json(
|
|
503
|
+
self._client.request_json(
|
|
504
|
+
"delete", endpoint, inject_group=self._client.has_owner_group
|
|
505
|
+
),
|
|
506
|
+
endpoint,
|
|
487
507
|
)
|
|
488
508
|
|
|
489
509
|
|
icmd/apps/comments.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Discussion comments and your comment notifications (``/api/v2/comments/``).
|
|
2
|
+
|
|
3
|
+
A discussion belongs to one item (a job, dataset, composition, ...), named by
|
|
4
|
+
``target_type`` and ``target_id``. Listing needs both, and a new comment
|
|
5
|
+
carries them in its body::
|
|
6
|
+
|
|
7
|
+
icmd = ICMD("icmd.ai")
|
|
8
|
+
on_job = {"target_type": "job", "target_id": "<uuid>"}
|
|
9
|
+
icmd.comments.comment.list(**on_job)
|
|
10
|
+
icmd.comments.comment.add({**on_job, "body": "Looks good"})
|
|
11
|
+
|
|
12
|
+
A comment's ``delete`` is final: nothing restores it. Notifications are the
|
|
13
|
+
caller's own feed, list-only; mark them read through
|
|
14
|
+
``icmd.comments.notification.action("mark_all_read", method="post")``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from .base import ReadOnlyResource, Resource
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from icmd.core.session import ResourceClient
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CommentsApp:
|
|
28
|
+
"""Discussion comments and comment notifications."""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("comment", "notification")
|
|
31
|
+
|
|
32
|
+
def __init__(self, client: ResourceClient) -> None:
|
|
33
|
+
self.comment = Resource(client, "comments")
|
|
34
|
+
self.notification = ReadOnlyResource(client, "comments/notifications")
|
icmd/apps/jobs.py
CHANGED
|
@@ -620,14 +620,16 @@ class JobResource(ReadOnlyResource):
|
|
|
620
620
|
"""Get the current status of a job (``GET /jobs/{uuid}/status/``)."""
|
|
621
621
|
endpoint = f"{self._path}/{uuid}/status"
|
|
622
622
|
return _expect_dict(
|
|
623
|
-
self._client.request_json("get", endpoint, inject_group=
|
|
623
|
+
self._client.request_json("get", endpoint, inject_group=self._client.has_owner_group),
|
|
624
|
+
endpoint,
|
|
624
625
|
)
|
|
625
626
|
|
|
626
627
|
def cancel(self, uuid: str) -> dict:
|
|
627
628
|
"""Cancel a running job (``POST /jobs/{uuid}/cancel/``)."""
|
|
628
629
|
endpoint = f"{self._path}/{uuid}/cancel"
|
|
629
630
|
return _expect_dict(
|
|
630
|
-
self._client.request_json("post", endpoint, inject_group=
|
|
631
|
+
self._client.request_json("post", endpoint, inject_group=self._client.has_owner_group),
|
|
632
|
+
endpoint,
|
|
631
633
|
)
|
|
632
634
|
|
|
633
635
|
def check_status(self, uuid: str) -> tuple[str, dict]:
|
|
@@ -671,7 +673,9 @@ class JobResource(ReadOnlyResource):
|
|
|
671
673
|
if from_timestamp is not None:
|
|
672
674
|
params["from_timestamp"] = from_timestamp
|
|
673
675
|
return _expect_dict( # type: ignore[return-value]
|
|
674
|
-
self._client.request_json(
|
|
676
|
+
self._client.request_json(
|
|
677
|
+
"get", endpoint, inject_group=self._client.has_owner_group, params=params or None
|
|
678
|
+
),
|
|
675
679
|
endpoint,
|
|
676
680
|
)
|
|
677
681
|
|
|
@@ -690,7 +694,9 @@ class JobResource(ReadOnlyResource):
|
|
|
690
694
|
"""
|
|
691
695
|
endpoint = f"{self._path}/{uuid}/log-entries"
|
|
692
696
|
return _expect_dict(
|
|
693
|
-
self._client.request_json(
|
|
697
|
+
self._client.request_json(
|
|
698
|
+
"get", endpoint, inject_group=self._client.has_owner_group, params=params or None
|
|
699
|
+
),
|
|
694
700
|
endpoint,
|
|
695
701
|
)
|
|
696
702
|
|
|
@@ -845,7 +851,9 @@ class JobResource(ReadOnlyResource):
|
|
|
845
851
|
"""
|
|
846
852
|
endpoint = f"{self._path}/{uuid}"
|
|
847
853
|
return _expect_dict(
|
|
848
|
-
self._client.request_json(
|
|
854
|
+
self._client.request_json(
|
|
855
|
+
"delete", endpoint, inject_group=self._client.has_owner_group
|
|
856
|
+
),
|
|
849
857
|
endpoint,
|
|
850
858
|
)
|
|
851
859
|
|
|
@@ -869,7 +877,9 @@ class JobResource(ReadOnlyResource):
|
|
|
869
877
|
)
|
|
870
878
|
endpoint = f"{self._path}/{uuid}/permanent-delete"
|
|
871
879
|
return _expect_dict(
|
|
872
|
-
self._client.request_json(
|
|
880
|
+
self._client.request_json(
|
|
881
|
+
"delete", endpoint, inject_group=self._client.has_owner_group
|
|
882
|
+
),
|
|
873
883
|
endpoint,
|
|
874
884
|
)
|
|
875
885
|
|
|
@@ -1530,7 +1540,7 @@ class JobResource(ReadOnlyResource):
|
|
|
1530
1540
|
self._client.request_json(
|
|
1531
1541
|
"post",
|
|
1532
1542
|
f"{self._path}/{uuid}/attach-plot",
|
|
1533
|
-
inject_group=
|
|
1543
|
+
inject_group=self._client.has_owner_group,
|
|
1534
1544
|
data={"source": json.dumps(transfer._plot_header(source), allow_nan=False)},
|
|
1535
1545
|
**transfer._figure_part(figure),
|
|
1536
1546
|
),
|
icmd/apps/results.py
CHANGED
|
@@ -228,7 +228,9 @@ class ResultsResource(ReadOnlyResource):
|
|
|
228
228
|
dict
|
|
229
229
|
The result record.
|
|
230
230
|
"""
|
|
231
|
-
return self.get(
|
|
231
|
+
return self.get(
|
|
232
|
+
uuid, params={"admin": "true"} if admin else None, inject_group=True if admin else None
|
|
233
|
+
)
|
|
232
234
|
|
|
233
235
|
def grant(
|
|
234
236
|
self,
|
icmd/cli/__init__.py
CHANGED
|
@@ -17,6 +17,7 @@ from icmd.core.exceptions import (
|
|
|
17
17
|
ICMDConnectionError,
|
|
18
18
|
ICMDRequestError,
|
|
19
19
|
ICMDValidationError,
|
|
20
|
+
ICMDVersionError,
|
|
20
21
|
)
|
|
21
22
|
from icmd.core.session import normalize_domain
|
|
22
23
|
from icmd.version import __version__
|
|
@@ -54,6 +55,7 @@ class _ICMDGroup(click.Group):
|
|
|
54
55
|
ICMDValidationError,
|
|
55
56
|
ICMDConnectionError,
|
|
56
57
|
ICMDRequestError,
|
|
58
|
+
ICMDVersionError,
|
|
57
59
|
) as exc:
|
|
58
60
|
name = type(exc).__name__.removeprefix("ICMD")
|
|
59
61
|
# A throttled refresh names its own wait; publish it as a field so
|
icmd/cli/commands.py
CHANGED
|
@@ -370,10 +370,9 @@ _DETAIL_ROUTE_CAUSES = (
|
|
|
370
370
|
"a project you cannot reach",
|
|
371
371
|
)
|
|
372
372
|
|
|
373
|
-
#: What
|
|
374
|
-
#:
|
|
375
|
-
#:
|
|
376
|
-
#: one the client never transmitted.
|
|
373
|
+
#: What a detail command's 404 says about scope when it sent no group. The CLI
|
|
374
|
+
#: client holds a group only for an explicit ``--group``; without one, naming a
|
|
375
|
+
#: group here would name one the client never transmitted.
|
|
377
376
|
_NO_GROUP_SENT = (
|
|
378
377
|
"none was sent, so the server used your current_group — whatever you last "
|
|
379
378
|
"selected in the web UI"
|
|
@@ -978,11 +977,12 @@ def _make_permanent_delete_cmd(path: str) -> click.Command:
|
|
|
978
977
|
|
|
979
978
|
|
|
980
979
|
#: The two halves of a detail action's scope, each offered on a 404 only when
|
|
981
|
-
#:
|
|
982
|
-
#:
|
|
980
|
+
#: the call did not already send it (through ``--param``, or ``--group`` for the
|
|
981
|
+
#: group): telling callers to re-run the flag they just typed is the
|
|
982
|
+
#: wild-goose chase :func:`_explaining_404` exists to end.
|
|
983
983
|
_ACTION_GROUP_REMEDY = (
|
|
984
|
-
"Add --param owner_group=<id> to scope the call
|
|
985
|
-
"no group on a detail route."
|
|
984
|
+
"Add --group <name|id> or --param owner_group=<id> to scope the call "
|
|
985
|
+
"explicitly — without either, action sends no group on a detail route."
|
|
986
986
|
)
|
|
987
987
|
_ACTION_ADMIN_REMEDY = (
|
|
988
988
|
"Add --param admin=true where the route honours it: it resolves the id as "
|
|
@@ -1013,6 +1013,7 @@ def _action_explanation( # noqa: PLR0913 — each input decides a part of the m
|
|
|
1013
1013
|
uuid: str | None,
|
|
1014
1014
|
method: str,
|
|
1015
1015
|
query: dict[str, str],
|
|
1016
|
+
client: Any,
|
|
1016
1017
|
) -> contextlib.AbstractContextManager[None]:
|
|
1017
1018
|
"""Build the ``404`` annotation an ``action`` call runs under.
|
|
1018
1019
|
|
|
@@ -1021,10 +1022,13 @@ def _action_explanation( # noqa: PLR0913 — each input decides a part of the m
|
|
|
1021
1022
|
"""
|
|
1022
1023
|
if not uuid:
|
|
1023
1024
|
return contextlib.nullcontext()
|
|
1024
|
-
#
|
|
1025
|
-
#
|
|
1026
|
-
#
|
|
1025
|
+
# Report the group the caller actually sent, and offer only the half they
|
|
1026
|
+
# left out: ``--param owner_group`` wins, else the SDK pins the client's
|
|
1027
|
+
# group, which the CLI holds only for an explicit ``--group``.
|
|
1027
1028
|
sent_group = query.get("owner_group")
|
|
1029
|
+
if "owner_group" not in query and client.has_owner_group:
|
|
1030
|
+
held = client.owner_group
|
|
1031
|
+
sent_group = f"{held['id']} ({held['name']})"
|
|
1028
1032
|
remedies: tuple[str, ...] = ()
|
|
1029
1033
|
if not sent_group:
|
|
1030
1034
|
remedies += (_ACTION_GROUP_REMEDY,)
|
|
@@ -1040,6 +1044,14 @@ def _action_explanation( # noqa: PLR0913 — each input decides a part of the m
|
|
|
1040
1044
|
return _explaining_404(uuid, scope=scope, remedies=remedies, cause=_action_name_cause(name))
|
|
1041
1045
|
|
|
1042
1046
|
|
|
1047
|
+
#: ``action`` names that cannot be undone whatever their method, so they take
|
|
1048
|
+
#: ``--yes`` like a ``--method delete``. The backend's MCP server confirms the
|
|
1049
|
+
#: same set, and its CLI parity test compares the two.
|
|
1050
|
+
IRREVERSIBLE_ACTIONS: frozenset[str] = frozenset(
|
|
1051
|
+
{"permanent-delete", "empty-trash", "detach-plot", "transfer-ownership", "developer-access"}
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
|
|
1043
1055
|
def _make_action_cmd(path: str) -> click.Command:
|
|
1044
1056
|
"""Build a generic ``action`` command — the CLI escape hatch for custom actions.
|
|
1045
1057
|
|
|
@@ -1078,7 +1090,10 @@ def _make_action_cmd(path: str) -> click.Command:
|
|
|
1078
1090
|
"assume_yes",
|
|
1079
1091
|
is_flag=True,
|
|
1080
1092
|
default=False,
|
|
1081
|
-
help=
|
|
1093
|
+
help=(
|
|
1094
|
+
"Skip the confirmation prompt for a destructive action: any --method delete, "
|
|
1095
|
+
f"and {', '.join(sorted(IRREVERSIBLE_ACTIONS))}."
|
|
1096
|
+
),
|
|
1082
1097
|
)
|
|
1083
1098
|
@_global_options
|
|
1084
1099
|
def action_cmd(**opts: Any) -> None:
|
|
@@ -1086,14 +1101,20 @@ def _make_action_cmd(path: str) -> click.Command:
|
|
|
1086
1101
|
body, body_file = opts["body"], opts["body_file"]
|
|
1087
1102
|
data = _load_json_body(body, body_file) if (body or body_file) else None
|
|
1088
1103
|
query = _parse_param_pairs(opts["params"])
|
|
1089
|
-
|
|
1090
|
-
|
|
1104
|
+
# Gate on the URL's last segment, not NAME: ``--id`` may carry the action itself.
|
|
1105
|
+
target = "/".join(p for p in (path, opts["uuid"], opts["name"]) if p)
|
|
1106
|
+
if (
|
|
1107
|
+
opts["method"] == "delete"
|
|
1108
|
+
or target.strip("/").rsplit("/", 1)[-1] in IRREVERSIBLE_ACTIONS
|
|
1109
|
+
):
|
|
1091
1110
|
_confirm_destructive(
|
|
1092
|
-
f"Run destructive action
|
|
1111
|
+
f"Run destructive action {opts['method'].upper()} {target}? "
|
|
1112
|
+
"This may be irreversible.",
|
|
1093
1113
|
yes=opts["assume_yes"],
|
|
1094
1114
|
)
|
|
1095
1115
|
ctx = click.get_current_context()
|
|
1096
|
-
|
|
1116
|
+
client = _build_client(ctx)
|
|
1117
|
+
resource = Resource(client, path)
|
|
1097
1118
|
with _action_explanation(
|
|
1098
1119
|
ctx,
|
|
1099
1120
|
path=path,
|
|
@@ -1101,6 +1122,7 @@ def _make_action_cmd(path: str) -> click.Command:
|
|
|
1101
1122
|
uuid=opts["uuid"],
|
|
1102
1123
|
method=opts["method"],
|
|
1103
1124
|
query=query,
|
|
1125
|
+
client=client,
|
|
1104
1126
|
):
|
|
1105
1127
|
result = resource.action(
|
|
1106
1128
|
opts["name"],
|
|
@@ -1232,23 +1254,31 @@ def _make_log_cmd(path: str) -> click.Command:
|
|
|
1232
1254
|
client = _build_client(ctx)
|
|
1233
1255
|
endpoint = f"{path}/{resource_id}/logs"
|
|
1234
1256
|
|
|
1235
|
-
# ``action logs --id`` reaches this exact endpoint by construction, and
|
|
1236
|
-
# unlike ``log`` it can carry an explicit scope through ``--param``.
|
|
1237
|
-
remedy = (
|
|
1238
|
-
f"`{ctx.parent.command_path} action logs --id {resource_id} " # type: ignore[union-attr]
|
|
1239
|
-
"--param owner_group=<id> --param admin=true` reaches the same "
|
|
1240
|
-
"endpoint with an explicit scope — log sends neither."
|
|
1241
|
-
)
|
|
1242
1257
|
# ``log`` is declared on ``studio/jobs`` alone (``registry.py:149``), so
|
|
1243
1258
|
# the ``accessible-group`` sentence needs no path check here.
|
|
1244
|
-
|
|
1245
|
-
|
|
1259
|
+
# ``action logs --id`` reaches this exact endpoint by construction, and
|
|
1260
|
+
# unlike ``log`` it can also send ``admin=true``.
|
|
1261
|
+
action_logs = f"`{ctx.parent.command_path} action logs --id {resource_id}" # type: ignore[union-attr]
|
|
1262
|
+
if client.has_owner_group:
|
|
1263
|
+
group = client.owner_group
|
|
1264
|
+
scope = f"?owner_group={group['id']} ({group['name']}) was sent"
|
|
1265
|
+
admin_remedy = (
|
|
1266
|
+
f"{action_logs} --group <name|id> --param admin=true` reads them as a group admin."
|
|
1267
|
+
)
|
|
1268
|
+
else:
|
|
1269
|
+
scope = f"{_NO_GROUP_SENT}; pass --group <name|id> to name one"
|
|
1270
|
+
admin_remedy = (
|
|
1271
|
+
f"{action_logs} --param owner_group=<id> --param admin=true` reaches "
|
|
1272
|
+
"the same endpoint with an explicit scope — log sent neither."
|
|
1273
|
+
)
|
|
1274
|
+
remedies = (admin_remedy, _accessible_group_remedy(ctx, resource_id))
|
|
1275
|
+
with _explaining_404(resource_id, scope=scope, remedies=remedies):
|
|
1246
1276
|
if not follow:
|
|
1247
1277
|
params: dict[str, str] = {}
|
|
1248
1278
|
if from_timestamp is not None:
|
|
1249
1279
|
params["from_timestamp"] = from_timestamp
|
|
1250
1280
|
result = client.request_json(
|
|
1251
|
-
"get", endpoint, inject_group=
|
|
1281
|
+
"get", endpoint, inject_group=client.has_owner_group, params=params or None
|
|
1252
1282
|
)
|
|
1253
1283
|
write_output(result, fmt=_format(ctx))
|
|
1254
1284
|
return
|
|
@@ -1274,7 +1304,9 @@ def _fetch_log_page(client: Any, endpoint: str, next_ts: str | None) -> dict[str
|
|
|
1274
1304
|
params: dict[str, str] = {}
|
|
1275
1305
|
if next_ts is not None:
|
|
1276
1306
|
params["from_timestamp"] = next_ts
|
|
1277
|
-
result = client.request_json(
|
|
1307
|
+
result = client.request_json(
|
|
1308
|
+
"get", endpoint, inject_group=client.has_owner_group, params=params or None
|
|
1309
|
+
)
|
|
1278
1310
|
if not isinstance(result, dict):
|
|
1279
1311
|
raise click.ClickException(
|
|
1280
1312
|
f"Unexpected log response from {endpoint}: expected dict, got {type(result).__name__}"
|
icmd/cli/registry.py
CHANGED
|
@@ -392,4 +392,28 @@ APP_REGISTRY: tuple[AppSpec, ...] = (
|
|
|
392
392
|
),
|
|
393
393
|
),
|
|
394
394
|
),
|
|
395
|
+
AppSpec(
|
|
396
|
+
"comments",
|
|
397
|
+
"Discussion comments and your comment notifications",
|
|
398
|
+
resources=(
|
|
399
|
+
# ``DELETE`` trashes a comment, but nothing restores it: ``hard``.
|
|
400
|
+
ResourceSpec(
|
|
401
|
+
"comment",
|
|
402
|
+
"comments",
|
|
403
|
+
writable=True,
|
|
404
|
+
delete_style="hard",
|
|
405
|
+
help=(
|
|
406
|
+
"Comments on a job, dataset or other item "
|
|
407
|
+
"(list needs --param target_type=job --param target_id=<uuid>)"
|
|
408
|
+
),
|
|
409
|
+
),
|
|
410
|
+
# Recipient-scoped and list-only: there is no detail ``GET``.
|
|
411
|
+
ResourceSpec(
|
|
412
|
+
"notification",
|
|
413
|
+
"comments/notifications",
|
|
414
|
+
actions=("list", "action"),
|
|
415
|
+
help="Your comment notifications (mark_read etc. via `action`)",
|
|
416
|
+
),
|
|
417
|
+
),
|
|
418
|
+
),
|
|
395
419
|
)
|
icmd/client.py
CHANGED
|
@@ -8,8 +8,9 @@ owner-group logic.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
from typing import TYPE_CHECKING, Any
|
|
11
|
+
from urllib.parse import urlsplit
|
|
11
12
|
|
|
12
|
-
from .apps import AccountApp, AgentApp, StudioApp, SuiteApp, WorkspaceApp
|
|
13
|
+
from .apps import AccountApp, AgentApp, CommentsApp, StudioApp, SuiteApp, WorkspaceApp
|
|
13
14
|
from .apps.base import _expect_dict
|
|
14
15
|
from .core.auth import Authenticator
|
|
15
16
|
|
|
@@ -24,13 +25,66 @@ from .core.auth import (
|
|
|
24
25
|
detect_auth_method,
|
|
25
26
|
display_banner,
|
|
26
27
|
)
|
|
27
|
-
from .core.constants import PUBLIC_CONFIG
|
|
28
|
+
from .core.constants import MIN_ICMD_VERSION, PUBLIC_CONFIG
|
|
28
29
|
from .core.contracts import ContractClient
|
|
29
30
|
from .core.credentials import CredentialManager
|
|
30
|
-
from .core.exceptions import ICMDRefreshTokenInvalidError, ICMDRequestError
|
|
31
|
+
from .core.exceptions import ICMDRefreshTokenInvalidError, ICMDRequestError, ICMDVersionError
|
|
31
32
|
from .core.groups import GroupManager
|
|
32
33
|
from .core.session import Session, normalize_domain
|
|
33
34
|
from .links import link_for
|
|
35
|
+
from .version import __version__
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _version_key(value: object) -> tuple[int, ...] | None:
|
|
39
|
+
"""Return *value* as comparable integers, or ``None`` when it is not a release number.
|
|
40
|
+
|
|
41
|
+
Trailing zeros are dropped so ``20.61.0`` and ``20.61.0.0`` compare equal.
|
|
42
|
+
``None`` (empty, a dev build, garbage) makes the caller skip that check.
|
|
43
|
+
"""
|
|
44
|
+
if not isinstance(value, str):
|
|
45
|
+
return None
|
|
46
|
+
parts = value.strip().split(".")
|
|
47
|
+
if not all(part.isdecimal() for part in parts):
|
|
48
|
+
return None
|
|
49
|
+
key = [int(part) for part in parts]
|
|
50
|
+
while key and key[-1] == 0:
|
|
51
|
+
key.pop()
|
|
52
|
+
return tuple(key)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _check_compatibility(session: Session) -> None:
|
|
56
|
+
"""Raise :class:`ICMDVersionError` when either side's minimum rules the other out.
|
|
57
|
+
|
|
58
|
+
The config is cached on the session, so the login banner reuses this fetch;
|
|
59
|
+
a failed one reads as ``{}`` and leaves connectivity to the login.
|
|
60
|
+
"""
|
|
61
|
+
config = session.get_server_config()
|
|
62
|
+
domain = urlsplit(session.domain).netloc
|
|
63
|
+
site_version = config.get("ICMD_VERSION") or ""
|
|
64
|
+
paired = config.get("ICMD_PYTHON_VERSION") or ""
|
|
65
|
+
# Unauthenticated input shown as a copy-paste command: only a release number qualifies.
|
|
66
|
+
install = (
|
|
67
|
+
f'\nInstall the version released with it: pip install "icmd-python=={paired}"'
|
|
68
|
+
if _version_key(paired) is not None
|
|
69
|
+
else ""
|
|
70
|
+
)
|
|
71
|
+
installed = _version_key(__version__)
|
|
72
|
+
site = _version_key(site_version)
|
|
73
|
+
|
|
74
|
+
sdk_minimum = config.get("ICMD_PYTHON_MINIMUM")
|
|
75
|
+
minimum = _version_key(sdk_minimum)
|
|
76
|
+
if installed is not None and minimum is not None and installed < minimum:
|
|
77
|
+
runs = f"{domain} runs ICMD® {site_version}" if site is not None else domain
|
|
78
|
+
raise ICMDVersionError(
|
|
79
|
+
f"{runs}, which needs icmd-python {sdk_minimum} or newer; "
|
|
80
|
+
f"you have {__version__}.{install}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if site is not None and site < (_version_key(MIN_ICMD_VERSION) or ()):
|
|
84
|
+
raise ICMDVersionError(
|
|
85
|
+
f"{domain} runs ICMD® {site_version}; icmd-python {__version__} needs "
|
|
86
|
+
f"ICMD® {MIN_ICMD_VERSION} or newer.{install}"
|
|
87
|
+
)
|
|
34
88
|
|
|
35
89
|
|
|
36
90
|
class ICMD:
|
|
@@ -75,6 +129,10 @@ class ICMD:
|
|
|
75
129
|
protocol.
|
|
76
130
|
ICMDValidationError
|
|
77
131
|
If *domain* is invalid.
|
|
132
|
+
ICMDVersionError
|
|
133
|
+
If the site serves a minimum SDK version above this one, or runs an
|
|
134
|
+
ICMD® release older than this SDK supports. Raised before any
|
|
135
|
+
login prompt; a site that serves no version is not checked.
|
|
78
136
|
ICMDTokenRefreshError
|
|
79
137
|
If a cached session could not be renewed for a transient reason —
|
|
80
138
|
a throttled (:class:`ICMDRefreshRateLimitedError`), unreachable, or
|
|
@@ -101,6 +159,7 @@ class ICMD:
|
|
|
101
159
|
self._authenticator = (
|
|
102
160
|
NotebookAuthenticator() if _in_jupyter() else TerminalAuthenticator()
|
|
103
161
|
)
|
|
162
|
+
_check_compatibility(self._session)
|
|
104
163
|
|
|
105
164
|
if developer_token:
|
|
106
165
|
self.auth_method: str = "DEVELOPER_TOKEN"
|
|
@@ -124,6 +183,7 @@ class ICMD:
|
|
|
124
183
|
self.studio = StudioApp(self)
|
|
125
184
|
self.workspace = WorkspaceApp(self)
|
|
126
185
|
self.agent = AgentApp(self)
|
|
186
|
+
self.comments = CommentsApp(self)
|
|
127
187
|
self.account = AccountApp(self)
|
|
128
188
|
|
|
129
189
|
# -----------------------------------------------------------------
|
|
@@ -433,6 +493,16 @@ class ICMD:
|
|
|
433
493
|
"""Return the active owner group as a full group dict."""
|
|
434
494
|
return self._groups.owner_group
|
|
435
495
|
|
|
496
|
+
@property
|
|
497
|
+
def has_owner_group(self) -> bool:
|
|
498
|
+
"""Whether this client already holds an owner group.
|
|
499
|
+
|
|
500
|
+
Set by the ``owner_group`` constructor argument, by
|
|
501
|
+
:meth:`set_owner_group`, or by the first request that resolved one.
|
|
502
|
+
Reading it never resolves a group, so it never prompts.
|
|
503
|
+
"""
|
|
504
|
+
return self._groups.has_owner_group
|
|
505
|
+
|
|
436
506
|
# -----------------------------------------------------------------
|
|
437
507
|
# Group management
|
|
438
508
|
# -----------------------------------------------------------------
|
|
@@ -474,7 +544,8 @@ class ICMD:
|
|
|
474
544
|
----------
|
|
475
545
|
inject_group : bool
|
|
476
546
|
If ``True`` (default), automatically inject the active
|
|
477
|
-
``owner_group`` as a query parameter
|
|
547
|
+
``owner_group`` as a query parameter; an ``owner_group`` already
|
|
548
|
+
in ``params`` is sent as given. ``False`` suppresses that
|
|
478
549
|
injection: group-scoped detail routes then fall back to the
|
|
479
550
|
caller's server-side ``current_group``, while other endpoints
|
|
480
551
|
apply their own scoping. It does not request a cross-group
|
icmd/compose.py
CHANGED
|
@@ -62,7 +62,7 @@ def _assemble_tuple(model: ModelInput) -> dict[str, Any]:
|
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def compose_input_dict(
|
|
65
|
-
composition: Mapping[str, Any] | Composition,
|
|
65
|
+
composition: Mapping[str, Any] | Composition | None,
|
|
66
66
|
models: Iterable[ModelInput | ModelNode],
|
|
67
67
|
variables: Mapping[str, Any] | Sequence[DesignVariable] | DesignSpace | None = None,
|
|
68
68
|
*,
|
|
@@ -110,9 +110,15 @@ def compose_input_dict(
|
|
|
110
110
|
|
|
111
111
|
Parameters
|
|
112
112
|
----------
|
|
113
|
-
composition : Mapping[str, Any] | Composition
|
|
113
|
+
composition : Mapping[str, Any] | Composition | None
|
|
114
114
|
The design composition, as a wire mapping or a
|
|
115
|
-
:class:`~icmd.types.Composition`.
|
|
115
|
+
:class:`~icmd.types.Composition`. ``None`` or an empty mapping emits no
|
|
116
|
+
``composition`` key, for a design whose models do not use
|
|
117
|
+
thermodynamics -- the "none" composition mode, which the Fatigue studio
|
|
118
|
+
defaults to. It stays a *required* positional argument, so spell that
|
|
119
|
+
case ``compose_input_dict(None, models)``; giving it a default would
|
|
120
|
+
mean defaulting ``models`` as well, and reordering the parameters would
|
|
121
|
+
break every positional caller.
|
|
116
122
|
models : Iterable[ModelInput | ModelNode]
|
|
117
123
|
The workflow's model instances, in either spelling described above.
|
|
118
124
|
variables : Mapping[str, Any] | Sequence[DesignVariable] | DesignSpace | None
|
icmd/core/advisory.py
CHANGED
|
@@ -84,13 +84,17 @@ class ContractProvider(Protocol):
|
|
|
84
84
|
"""Return the ACL-filtered contract for *model_uuid*."""
|
|
85
85
|
...
|
|
86
86
|
|
|
87
|
+
def envelope(self) -> dict[str, Any]:
|
|
88
|
+
"""Return the job-envelope JSON Schema the backend validates submissions against."""
|
|
89
|
+
...
|
|
90
|
+
|
|
87
91
|
|
|
88
92
|
class _ModelInput(TypedDict):
|
|
89
93
|
"""An assembled model node narrowed for advisory checking.
|
|
90
94
|
|
|
91
95
|
``name`` is the node's ``qt_model.key``, or its name when it carries no
|
|
92
96
|
key. ``uuid`` is ``None`` for a node that does not carry its catalog uuid.
|
|
93
|
-
Such a node still gets the
|
|
97
|
+
Such a node still gets the globals half of the check -- which reads the
|
|
94
98
|
document alone -- and forces the contract half to report itself skipped,
|
|
95
99
|
because there is no uuid to fetch a contract with.
|
|
96
100
|
"""
|
|
@@ -315,19 +319,20 @@ def _validator_for(schema_key: str) -> Any:
|
|
|
315
319
|
return _construct_validator(schema_key)
|
|
316
320
|
|
|
317
321
|
|
|
322
|
+
def _schema_validator(schema: dict[str, Any]) -> Any:
|
|
323
|
+
"""Return the cached validator for *schema*, without re-checking its meta-schema."""
|
|
324
|
+
return _validator_for(
|
|
325
|
+
json.dumps(schema, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
318
329
|
def _validation_errors(
|
|
319
330
|
schema: dict[str, Any],
|
|
320
331
|
model_input: dict[str, Any],
|
|
321
332
|
location: tuple[str | int, ...],
|
|
322
333
|
) -> list[Any]:
|
|
323
334
|
"""Validate one model input without repeatedly checking its meta-schema."""
|
|
324
|
-
|
|
325
|
-
schema,
|
|
326
|
-
ensure_ascii=False,
|
|
327
|
-
separators=(",", ":"),
|
|
328
|
-
sort_keys=True,
|
|
329
|
-
)
|
|
330
|
-
validator = _validator_for(schema_key)
|
|
335
|
+
validator = _schema_validator(schema)
|
|
331
336
|
return sorted(
|
|
332
337
|
validator.iter_errors(model_input),
|
|
333
338
|
key=lambda error: _json_path((*location, *error.absolute_path)),
|
|
@@ -356,6 +361,92 @@ def _skipped(reason: str) -> list[AdvisoryFinding]:
|
|
|
356
361
|
return [_finding("", None, "$", f"check skipped: {reason}", "skipped")]
|
|
357
362
|
|
|
358
363
|
|
|
364
|
+
def _named_at_submit(error: Any) -> bool:
|
|
365
|
+
"""Whether *error* is the missing name of a key-composed model node.
|
|
366
|
+
|
|
367
|
+
The backend names a catalog node from its key at submit binding, before
|
|
368
|
+
its envelope gate, so a catalog node's missing name is not a refusal. A
|
|
369
|
+
trained-model node composed by key without a name is suppressed too, yet
|
|
370
|
+
refused at submit: the backend does not name it, and telling its uuid
|
|
371
|
+
from a catalog one would take a lookup this check does not make.
|
|
372
|
+
"""
|
|
373
|
+
return (
|
|
374
|
+
error.validator == "required"
|
|
375
|
+
and error.message == "'name' is a required property"
|
|
376
|
+
and bool(error.absolute_path)
|
|
377
|
+
and error.absolute_path[-1] == "qt_model"
|
|
378
|
+
and isinstance(error.instance, dict)
|
|
379
|
+
and bool(error.instance.get("key"))
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _envelope_error_findings(error: Any, definitions: dict[str, Any]) -> list[AdvisoryFinding]:
|
|
384
|
+
"""Report one envelope error against the union branch the document selects.
|
|
385
|
+
|
|
386
|
+
Mirrors qt-designer's ``validate_envelope``: a discriminated union is read
|
|
387
|
+
through the branch its tag names, and ``X | None`` through ``X``, so a
|
|
388
|
+
sampling input without ``type`` reads as ``required`` at the sampling input
|
|
389
|
+
rather than "not valid under any of the given schemas".
|
|
390
|
+
"""
|
|
391
|
+
location = _json_path(tuple(error.absolute_path))
|
|
392
|
+
branches = error.schema.get(error.validator) if error.validator in ("anyOf", "oneOf") else None
|
|
393
|
+
if not branches:
|
|
394
|
+
return [_finding("", None, location, error.message, str(error.validator))]
|
|
395
|
+
if "discriminator" in error.schema:
|
|
396
|
+
tag_name = error.schema["discriminator"]["propertyName"]
|
|
397
|
+
if not isinstance(error.instance, dict):
|
|
398
|
+
return [] # The union's own ``type: object`` reports it.
|
|
399
|
+
if tag_name not in error.instance:
|
|
400
|
+
message = f"{tag_name!r} is a required property"
|
|
401
|
+
return [_finding("", None, location, message, "required")]
|
|
402
|
+
tags = [
|
|
403
|
+
definitions[branch["$ref"].removeprefix("#/$defs/")]["properties"][tag_name]["const"]
|
|
404
|
+
for branch in branches
|
|
405
|
+
]
|
|
406
|
+
tag = error.instance[tag_name]
|
|
407
|
+
if tag not in tags:
|
|
408
|
+
return [
|
|
409
|
+
_finding("", None, location, f"{tag_name} {tag!r} is not one of {tags}", "enum")
|
|
410
|
+
]
|
|
411
|
+
selected = tags.index(tag)
|
|
412
|
+
else:
|
|
413
|
+
non_null = [index for index, branch in enumerate(branches) if branch != {"type": "null"}]
|
|
414
|
+
if error.instance is None or len(non_null) != 1:
|
|
415
|
+
return [_finding("", None, location, error.message, str(error.validator))]
|
|
416
|
+
selected = non_null[0]
|
|
417
|
+
return [
|
|
418
|
+
finding
|
|
419
|
+
for sub_error in error.context or []
|
|
420
|
+
if sub_error.relative_schema_path[0] == selected
|
|
421
|
+
for finding in _envelope_error_findings(sub_error, definitions)
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _envelope_findings(
|
|
426
|
+
input_dict: dict[str, Any],
|
|
427
|
+
contracts: ContractProvider,
|
|
428
|
+
) -> list[AdvisoryFinding]:
|
|
429
|
+
"""Validate the whole document against the served job envelope.
|
|
430
|
+
|
|
431
|
+
An envelope that cannot be fetched or applied is one ``skipped`` finding,
|
|
432
|
+
never an empty list: an unchecked envelope must not read as a valid one.
|
|
433
|
+
"""
|
|
434
|
+
envelope = getattr(contracts, "envelope", None)
|
|
435
|
+
if envelope is None:
|
|
436
|
+
return _skipped("job envelope: this contract provider has no envelope()")
|
|
437
|
+
try:
|
|
438
|
+
schema = envelope()
|
|
439
|
+
definitions = schema.get("$defs", {})
|
|
440
|
+
return [
|
|
441
|
+
finding
|
|
442
|
+
for error in _schema_validator(schema).iter_errors(input_dict)
|
|
443
|
+
if not _named_at_submit(error)
|
|
444
|
+
for finding in _envelope_error_findings(error, definitions)
|
|
445
|
+
]
|
|
446
|
+
except Exception as exc:
|
|
447
|
+
return _skipped(f"job envelope: {str(exc).strip() or type(exc).__name__}")
|
|
448
|
+
|
|
449
|
+
|
|
359
450
|
def _canonical_global(key: str) -> str | None:
|
|
360
451
|
"""Name the global a ``use_global`` sitting under *key* resolves against.
|
|
361
452
|
|
|
@@ -564,30 +655,33 @@ def check_input_dict(
|
|
|
564
655
|
input_dict: dict[str, Any],
|
|
565
656
|
contracts: ContractProvider,
|
|
566
657
|
) -> list[AdvisoryFinding]:
|
|
567
|
-
"""Check assembled
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
658
|
+
"""Check an assembled job input and return non-blocking findings.
|
|
659
|
+
|
|
660
|
+
Three independent parts, reported in this order. The job envelope comes
|
|
661
|
+
first: the whole document against the schema the backend serves, or one
|
|
662
|
+
``"check skipped"`` finding when that schema cannot be fetched. The globals
|
|
663
|
+
check reads the document alone: a model deferring to a global the job never
|
|
664
|
+
defines is reported here rather than failing worker-side once the job is
|
|
665
|
+
claimed. Contract validation is last, and if any model's contract cannot be
|
|
666
|
+
fetched it contributes one ``"check skipped"`` finding instead of its own,
|
|
667
|
+
placed ahead of the globals findings, so partial contract output cannot be
|
|
575
668
|
mistaken for a complete check.
|
|
576
669
|
|
|
577
|
-
|
|
670
|
+
No part's skip suppresses another's findings -- an old or unreachable
|
|
578
671
|
backend is where a pre-submit warning is worth most -- so an incomplete
|
|
579
672
|
check is recognized by a ``kind == "skipped"`` finding, never by an empty
|
|
580
|
-
result.
|
|
673
|
+
result.
|
|
581
674
|
"""
|
|
675
|
+
envelope_findings = _envelope_findings(input_dict, contracts)
|
|
582
676
|
try:
|
|
583
677
|
models = _model_inputs(input_dict)
|
|
584
678
|
global_findings = [
|
|
585
679
|
finding for model in models for finding in _global_findings(model, input_dict)
|
|
586
680
|
]
|
|
587
681
|
except Exception as exc:
|
|
588
|
-
return _skipped(str(exc).strip() or type(exc).__name__)
|
|
682
|
+
return [*envelope_findings, *_skipped(str(exc).strip() or type(exc).__name__)]
|
|
589
683
|
|
|
590
684
|
contract_findings, reason = _contract_findings(models, contracts)
|
|
591
685
|
if reason is not None:
|
|
592
|
-
return [*_skipped(reason), *global_findings]
|
|
593
|
-
return [*global_findings, *contract_findings]
|
|
686
|
+
return [*envelope_findings, *_skipped(reason), *global_findings]
|
|
687
|
+
return [*envelope_findings, *global_findings, *contract_findings]
|
icmd/core/auth.py
CHANGED
|
@@ -495,14 +495,11 @@ def enhance_auth_error(
|
|
|
495
495
|
def display_banner(session: Session, authenticator: Authenticator) -> None:
|
|
496
496
|
"""Display the server config's banner message, if there is one.
|
|
497
497
|
|
|
498
|
-
An authenticator may set ``displays_messages = False`` to opt out
|
|
499
|
-
|
|
500
|
-
|
|
498
|
+
An authenticator may set ``displays_messages = False`` to opt out of the
|
|
499
|
+
display. Authenticators that do not declare the optional flag display the
|
|
500
|
+
banner.
|
|
501
501
|
"""
|
|
502
502
|
try:
|
|
503
|
-
# Skips the fetch, not merely the display: this round-trip is made
|
|
504
|
-
# solely to render the banner, so an authenticator that discards
|
|
505
|
-
# messages would spend a request per process on output nobody sees.
|
|
506
503
|
# Defaulting to True preserves banner display for authenticators that
|
|
507
504
|
# do not declare the flag. Inside the try because a declared property
|
|
508
505
|
# may raise something other than AttributeError, which getattr's
|
icmd/core/constants.py
CHANGED
|
@@ -61,3 +61,12 @@ compare its ``contract_sha256`` to decide whether a refresh is warranted.
|
|
|
61
61
|
|
|
62
62
|
# Public
|
|
63
63
|
PUBLIC_CONFIG = "public/config"
|
|
64
|
+
|
|
65
|
+
# Compatibility
|
|
66
|
+
MIN_ICMD_VERSION = "20.49.0"
|
|
67
|
+
"""Oldest ICMD® release this SDK supports; a site below it is refused on connect.
|
|
68
|
+
|
|
69
|
+
20.49.0 is where the backend binds model nodes by key, which is the only form
|
|
70
|
+
this SDK composes. Raise it in the change that drops a fallback for an older
|
|
71
|
+
backend.
|
|
72
|
+
"""
|
icmd/core/contracts.py
CHANGED
|
@@ -67,8 +67,9 @@ class ContractClient:
|
|
|
67
67
|
The cache key includes the current owner group, resolved on every call
|
|
68
68
|
because callers may change groups after constructing
|
|
69
69
|
:class:`~icmd.client.ICMD`. Generated classes hang off the entry, so no
|
|
70
|
-
class outlives the group and the scenario set it was licensed by.
|
|
71
|
-
|
|
70
|
+
class outlives the group and the scenario set it was licensed by. All three
|
|
71
|
+
-- contracts, their classes and the job envelope schema (one per client,
|
|
72
|
+
not group-scoped) -- are memory-only and :meth:`clear` drops them together.
|
|
72
73
|
"""
|
|
73
74
|
|
|
74
75
|
def __init__(
|
|
@@ -84,6 +85,7 @@ class ContractClient:
|
|
|
84
85
|
self._ttl = ttl
|
|
85
86
|
self._clock = clock
|
|
86
87
|
self._cache: dict[tuple[str, Hashable, str], _CacheEntry] = {}
|
|
88
|
+
self._envelope: tuple[dict[str, Any], float] | None = None
|
|
87
89
|
|
|
88
90
|
def _cache_key(self, model_uuid: str) -> tuple[str, Hashable, str]:
|
|
89
91
|
"""Build a key from domain, the live effective group, and model UUID."""
|
|
@@ -193,6 +195,45 @@ class ContractClient:
|
|
|
193
195
|
"""Return a model contract, re-fetching once the cached copy expires."""
|
|
194
196
|
return deepcopy(self._entry(model_uuid).contract)
|
|
195
197
|
|
|
198
|
+
def envelope(self) -> dict[str, Any]:
|
|
199
|
+
"""Return the job-envelope JSON Schema, re-fetching once the cached copy expires.
|
|
200
|
+
|
|
201
|
+
The schema comes from ``studio/input-dicts/schema/`` and is the one the
|
|
202
|
+
backend validates every submitted job document against. It is not
|
|
203
|
+
owner-group scoped, so one copy serves the whole client, on the same TTL
|
|
204
|
+
as contracts.
|
|
205
|
+
|
|
206
|
+
Raises
|
|
207
|
+
------
|
|
208
|
+
ICMDRequestError
|
|
209
|
+
If the request fails or the body carries no schema object.
|
|
210
|
+
"""
|
|
211
|
+
now = self._clock()
|
|
212
|
+
if self._envelope is not None and now - self._envelope[1] < self._ttl:
|
|
213
|
+
return deepcopy(self._envelope[0])
|
|
214
|
+
response = self._session.api_request("GET", "studio/input-dicts/schema/")
|
|
215
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
216
|
+
raise ICMDRequestError(
|
|
217
|
+
"This ICMD® backend does not serve the job envelope schema (it predates it).",
|
|
218
|
+
status_code=response.status_code,
|
|
219
|
+
)
|
|
220
|
+
check_response(response, "fetch job envelope schema")
|
|
221
|
+
try:
|
|
222
|
+
body = response.json()
|
|
223
|
+
except ValueError as exc:
|
|
224
|
+
raise ICMDRequestError(
|
|
225
|
+
"Expected a JSON job envelope schema.",
|
|
226
|
+
status_code=response.status_code,
|
|
227
|
+
) from exc
|
|
228
|
+
schema = body.get("schema") if isinstance(body, dict) else None
|
|
229
|
+
if not isinstance(schema, dict):
|
|
230
|
+
raise ICMDRequestError(
|
|
231
|
+
"Expected a job envelope schema object.",
|
|
232
|
+
status_code=response.status_code,
|
|
233
|
+
)
|
|
234
|
+
self._envelope = (schema, now)
|
|
235
|
+
return deepcopy(schema)
|
|
236
|
+
|
|
196
237
|
def model_class(self, model_uuid: str, scenario: str | None = None) -> type[BaseModel]:
|
|
197
238
|
"""Return the pydantic input class one model scenario describes.
|
|
198
239
|
|
|
@@ -253,5 +294,6 @@ class ContractClient:
|
|
|
253
294
|
return check_input_dict(input_dict, self)
|
|
254
295
|
|
|
255
296
|
def clear(self) -> None:
|
|
256
|
-
"""Clear cached contracts
|
|
297
|
+
"""Clear cached contracts, the classes built from them, and the envelope schema."""
|
|
257
298
|
self._cache.clear()
|
|
299
|
+
self._envelope = None
|
icmd/core/exceptions.py
CHANGED
|
@@ -49,6 +49,15 @@ class ICMDConnectionError(Exception):
|
|
|
49
49
|
"""Raised when connection to ICMD® fails."""
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
class ICMDVersionError(Exception):
|
|
53
|
+
"""Raised when this SDK and the ICMD® site it connects to cannot work together.
|
|
54
|
+
|
|
55
|
+
Either side may refuse: the site serves the oldest SDK it supports, and the
|
|
56
|
+
SDK declares the oldest ICMD® release it supports. The message names the
|
|
57
|
+
version to install when the site says which icmd-python it was released with.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
52
61
|
class ICMDValidationError(Exception):
|
|
53
62
|
"""Raised when input validation fails."""
|
|
54
63
|
|
icmd/core/groups.py
CHANGED
|
@@ -71,6 +71,11 @@ class GroupManager:
|
|
|
71
71
|
self._owner_group = self._resolve_owner_group()
|
|
72
72
|
return self._owner_group
|
|
73
73
|
|
|
74
|
+
@property
|
|
75
|
+
def has_owner_group(self) -> bool:
|
|
76
|
+
"""Whether a group is already held, without resolving one."""
|
|
77
|
+
return self._owner_group is not None
|
|
78
|
+
|
|
74
79
|
def set_owner_group(self, group: int | str | None = None) -> None:
|
|
75
80
|
"""Set the owner group for subsequent API operations.
|
|
76
81
|
|
|
@@ -123,13 +128,15 @@ class GroupManager:
|
|
|
123
128
|
def inject_params(self, kwargs: dict[str, Any]) -> None:
|
|
124
129
|
"""Inject ``owner_group`` as a query parameter, resolving lazily if needed.
|
|
125
130
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
An ``owner_group`` the caller already names in ``params`` is sent as
|
|
132
|
+
given, and no group is resolved for it. The caller's dict is copied,
|
|
133
|
+
never mutated. No recursion risk: :meth:`_resolve_owner_group`
|
|
134
|
+
fetches groups via ``Session.request_json`` directly, which does not
|
|
135
|
+
call back into this method.
|
|
129
136
|
"""
|
|
130
|
-
|
|
131
|
-
params
|
|
132
|
-
|
|
137
|
+
params = dict(kwargs.get("params") or {})
|
|
138
|
+
if params.get("owner_group") is None:
|
|
139
|
+
params["owner_group"] = self.owner_group["id"]
|
|
133
140
|
kwargs["params"] = params
|
|
134
141
|
|
|
135
142
|
# -----------------------------------------------------------------
|
icmd/core/session.py
CHANGED
icmd/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "3.
|
|
1
|
+
__version__ = "3.14.0"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: icmd-python
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.14.0
|
|
4
4
|
Summary: Python client app for ICMD®
|
|
5
5
|
Author: QuesTek Innovations LLC
|
|
6
6
|
License-Expression: LicenseRef-Proprietary
|
|
@@ -116,8 +116,8 @@ errors go to stderr.
|
|
|
116
116
|
|
|
117
117
|
- `ICMD` — the client facade. Constructing it authenticates (interactive sign-in, a developer
|
|
118
118
|
token, or a stored credential) and binds the session to one tenant.
|
|
119
|
-
- `client.suite`, `client.studio`, `client.workspace`, `client.agent`, `client.account
|
|
120
|
-
areas. Most expose resource objects with a uniform surface: `list()` and `list_all()`, `get()`,
|
|
119
|
+
- `client.suite`, `client.studio`, `client.workspace`, `client.agent`, `client.account`,
|
|
120
|
+
`client.comments` — the API areas. Most expose resource objects with a uniform surface: `list()` and `list_all()`, `get()`,
|
|
121
121
|
and, where the endpoint allows it, `add()`, `update()`, and `delete()`.
|
|
122
122
|
- `client.contracts` — model contracts fetched from the server, and the advisory check that reads
|
|
123
123
|
a job input against them.
|
|
@@ -1,52 +1,53 @@
|
|
|
1
|
-
icmd/__init__.py,sha256
|
|
2
|
-
icmd/client.py,sha256=
|
|
3
|
-
icmd/compose.py,sha256
|
|
1
|
+
icmd/__init__.py,sha256=TME0_M-oG3U7-rrdQ4cLzcp7JvgBeP0BhjjiDOshbaY,3155
|
|
2
|
+
icmd/client.py,sha256=F9qwRrC1cfEFTw0FZgI7PWXX0J_B-bzv0CXs2R_R5EM,25606
|
|
3
|
+
icmd/compose.py,sha256=kl_-yYiVRfglpyNn782iuzg54-_55Rf3XJnkqSY_Zq0,7563
|
|
4
4
|
icmd/links.py,sha256=I8v8_xhI6lbUwrMjIUcYYcqKUi2PIPm6zQ7mXYP9A5E,6827
|
|
5
5
|
icmd/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
icmd/types.py,sha256=af26gLTblS33vhlYdHLSPKY7Rm1shJDDOpzauCeOxAE,81323
|
|
7
|
-
icmd/version.py,sha256=
|
|
7
|
+
icmd/version.py,sha256=xtcy_5Xf21-Fg12NJ-BUaHz9wwW-QCIv0Ni0UXGxy0s,23
|
|
8
8
|
icmd/_vendor/__init__.py,sha256=VFA4EQd2l76sxgkHPUpgzZavFF6kFdpq9v71AZsPIdQ,579
|
|
9
|
-
icmd/_vendor/assembler.py,sha256=
|
|
10
|
-
icmd/apps/__init__.py,sha256=
|
|
9
|
+
icmd/_vendor/assembler.py,sha256=3mOONZyT_J2WOVs06xofoxv0nSjBgSfPFV3nj-sUZGw,6176
|
|
10
|
+
icmd/apps/__init__.py,sha256=Bg6rR2Zt38OHdBBwGKBzSqbsDW8chVM9KwjZ4IGODXE,712
|
|
11
11
|
icmd/apps/account.py,sha256=BWDO0Zysv0ygLVhbeIk82BjsvuDVb_vBdu6jqBPw3tc,14557
|
|
12
|
-
icmd/apps/agent.py,sha256=
|
|
13
|
-
icmd/apps/base.py,sha256=
|
|
12
|
+
icmd/apps/agent.py,sha256=EasQ0ZHbBbqXauAcoSym-eK2TOFDbdd2XGJPnKYBx6k,8467
|
|
13
|
+
icmd/apps/base.py,sha256=kyowDVglcXok90DiM4GuGEObusH1m86ZWbWKLiGT8Aw,19931
|
|
14
|
+
icmd/apps/comments.py,sha256=gnsyQvUVkSix4nxRjqHr2Jpz69iDQmbMmOTM5IClmWQ,1152
|
|
14
15
|
icmd/apps/constants.py,sha256=5xoIwFNGOaAp2cHAlVDlbzJ7ObQHYXJgVGMBfNLCHzI,757
|
|
15
16
|
icmd/apps/documents.py,sha256=8_wFc7NLUFQMYZA5hqsciXniHECZEWS-egX_ni8F4LE,3720
|
|
16
|
-
icmd/apps/jobs.py,sha256
|
|
17
|
+
icmd/apps/jobs.py,sha256=-OFCJNgeBJGfuq4joA29NHDcP4ZMLSc24VYN3NonuRA,67231
|
|
17
18
|
icmd/apps/navigation.py,sha256=mbEJG33Z-Nha5txvMpL63sT-UqsgFgZ-NMhTU7L7gI0,3738
|
|
18
|
-
icmd/apps/results.py,sha256=
|
|
19
|
+
icmd/apps/results.py,sha256=vXT3mVZSNn_to_zYw3tO_SMIc1YpKbYxjBd_aOizRHI,18264
|
|
19
20
|
icmd/apps/results_store.py,sha256=19g6XQVBpKGLC3o1RU_Dn6IV83Qtaq3i1d41m5yBQf4,12439
|
|
20
21
|
icmd/apps/studio.py,sha256=XN6gVCqkTzp7qH9I-UOhW7UJ2RfkGv60H-G7ulkR850,2419
|
|
21
22
|
icmd/apps/suite.py,sha256=1XT8aKPn15W-bwBdOz-JV3Wrmr_JOKyrMEC6--ljj9A,3907
|
|
22
23
|
icmd/apps/transfer.py,sha256=UVVEOmYynLzRtdTO6dbUBm6om6oHgmnspuKjACp0arE,36736
|
|
23
24
|
icmd/apps/types.py,sha256=jOzykkXWj9e_lk805_ABCC-sWBwIpJGN3HEb2AyQ9vw,1290
|
|
24
25
|
icmd/apps/workspace.py,sha256=BXCOLQo5BMu-okW2Ink8L_FPhU8r7OWy2jDsvilU3Oo,3055
|
|
25
|
-
icmd/cli/__init__.py,sha256=
|
|
26
|
+
icmd/cli/__init__.py,sha256=KiXdv_n5SOPqBxtTFQSnfqMf-T1ScxuOrbdkB_GUHp8,7447
|
|
26
27
|
icmd/cli/account.py,sha256=equTAGWR3NCCMYEPTw4IZKHEtBEdGD8F4Pp8cfiSNoU,13859
|
|
27
28
|
icmd/cli/auth.py,sha256=UFpEf-lLlkbphivgBfd9NxAEqHcCsPmEl-DXet-NStg,3317
|
|
28
29
|
icmd/cli/auth_status.py,sha256=6wkI3Q0c-t8DBOfuPaN_xXEUcL9HLZm1jX4lQ76X3Ag,13505
|
|
29
30
|
icmd/cli/client.py,sha256=3iToPSABFYWWUPn-uDVFAv9GoBWQXP04l2Id_aYgd64,2186
|
|
30
|
-
icmd/cli/commands.py,sha256=
|
|
31
|
+
icmd/cli/commands.py,sha256=z_LngviZ5tcMJx-gmsFfllAYpxvhsNqNFra2aVDrQ9Q,65158
|
|
31
32
|
icmd/cli/diagnostics.py,sha256=1fXgKmpUMA5U-_79AzZ0uSibFmDTK_CLCe91YhBnM8s,2747
|
|
32
33
|
icmd/cli/domain.py,sha256=MiGzdKwKaOEvMshsPPJTmdBm7pCTjGM98MOg9RhAL_k,874
|
|
33
34
|
icmd/cli/links.py,sha256=7CMhKUJ-qKQmk2I-KCdn6oF06cdCq0v5jQDGU7gEJ30,14039
|
|
34
35
|
icmd/cli/output.py,sha256=-TKZ8fJYCUhh7qHRNAZyFYFaXcMDqm4NDR2qFfErg5A,1190
|
|
35
|
-
icmd/cli/registry.py,sha256=
|
|
36
|
+
icmd/cli/registry.py,sha256=MEkqcu2b6ZjfmE2L2L062PxTjOTjlVEyYSycTv_tq30,17577
|
|
36
37
|
icmd/core/__init__.py,sha256=45q0vlUao61rG6ugufGTRtoDeBTDcscU5dLAMluDSBA,225
|
|
37
|
-
icmd/core/advisory.py,sha256=
|
|
38
|
-
icmd/core/auth.py,sha256=
|
|
39
|
-
icmd/core/constants.py,sha256=
|
|
40
|
-
icmd/core/contracts.py,sha256=
|
|
38
|
+
icmd/core/advisory.py,sha256=gQqaPUfSNhUQFG61SPqaZQhd_7AfzPgw8lNXhwulZhc,26994
|
|
39
|
+
icmd/core/auth.py,sha256=WRY2BStFYQqSnk__oEQTrAvbvVCHQdm_G2YYOcHL_jY,29207
|
|
40
|
+
icmd/core/constants.py,sha256=sKSy5oRqUKztlesyjirMEGrA9FmR4Kz-uPrDE8rv33A,2638
|
|
41
|
+
icmd/core/contracts.py,sha256=QXMpxiPBltaXFfGCJx4Y2d7tkRyjWAIBragSg_NucXE,12575
|
|
41
42
|
icmd/core/credentials.py,sha256=39vyoFH1_MrzD9TpTh4FVPhWbrY5X35zGCS22KeTi_8,46835
|
|
42
43
|
icmd/core/display.py,sha256=RkgJhr2Bfw3-ZjGvE100ddD6oyAAUUAJfBAy6DvX5ik,13060
|
|
43
|
-
icmd/core/exceptions.py,sha256=
|
|
44
|
-
icmd/core/groups.py,sha256=
|
|
44
|
+
icmd/core/exceptions.py,sha256=c88w5neJU7ohm5iEboA29jFaUfZ8EfrxmiO9rornTTw,13035
|
|
45
|
+
icmd/core/groups.py,sha256=BEwpBEe60l_8HpnsIvLPC3EhLSXnv-qyo-OZIyP0sWI,7585
|
|
45
46
|
icmd/core/model_classes.py,sha256=uuhJwh_AZ69eOp8SoWYo0MM5rNwA_a-vkxhrIwFasds,40372
|
|
46
|
-
icmd/core/session.py,sha256=
|
|
47
|
-
icmd_python-3.
|
|
48
|
-
icmd_python-3.
|
|
49
|
-
icmd_python-3.
|
|
50
|
-
icmd_python-3.
|
|
51
|
-
icmd_python-3.
|
|
52
|
-
icmd_python-3.
|
|
47
|
+
icmd/core/session.py,sha256=I3X0z2ZZm9H68NLnOOs4vS6sn4THWLNjvgUcBDJB5i8,60902
|
|
48
|
+
icmd_python-3.14.0.dist-info/licenses/LICENSE,sha256=644lGvCmDFa5hQl6xdhaYk3o-1oWqPXAIiiQlvqeZdE,1028
|
|
49
|
+
icmd_python-3.14.0.dist-info/METADATA,sha256=-ZmtyDRQTcleRU64XI03xSfBVISxKNI7D6BSQkeks9Q,5358
|
|
50
|
+
icmd_python-3.14.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
51
|
+
icmd_python-3.14.0.dist-info/entry_points.txt,sha256=CFX537PlyyPQL3Q_3TKUFeiiDSkOAboqaLEy9AIEwVU,39
|
|
52
|
+
icmd_python-3.14.0.dist-info/top_level.txt,sha256=olzM6Z_QEwg7RrFVOnkgO0I2Ps2m7vhSqYQZYeiawcw,5
|
|
53
|
+
icmd_python-3.14.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|