icmd-python 3.10.0__py3-none-any.whl → 3.11.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/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/commands.py +59 -27
- icmd/cli/registry.py +24 -0
- icmd/client.py +14 -2
- 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.11.0.dist-info}/METADATA +3 -3
- {icmd_python-3.10.0.dist-info → icmd_python-3.11.0.dist-info}/RECORD +18 -17
- {icmd_python-3.10.0.dist-info → icmd_python-3.11.0.dist-info}/WHEEL +1 -1
- {icmd_python-3.10.0.dist-info → icmd_python-3.11.0.dist-info}/entry_points.txt +0 -0
- {icmd_python-3.10.0.dist-info → icmd_python-3.11.0.dist-info}/licenses/LICENSE +0 -0
- {icmd_python-3.10.0.dist-info → icmd_python-3.11.0.dist-info}/top_level.txt +0 -0
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/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
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from typing import TYPE_CHECKING, Any
|
|
11
11
|
|
|
12
|
-
from .apps import AccountApp, AgentApp, StudioApp, SuiteApp, WorkspaceApp
|
|
12
|
+
from .apps import AccountApp, AgentApp, CommentsApp, StudioApp, SuiteApp, WorkspaceApp
|
|
13
13
|
from .apps.base import _expect_dict
|
|
14
14
|
from .core.auth import Authenticator
|
|
15
15
|
|
|
@@ -124,6 +124,7 @@ class ICMD:
|
|
|
124
124
|
self.studio = StudioApp(self)
|
|
125
125
|
self.workspace = WorkspaceApp(self)
|
|
126
126
|
self.agent = AgentApp(self)
|
|
127
|
+
self.comments = CommentsApp(self)
|
|
127
128
|
self.account = AccountApp(self)
|
|
128
129
|
|
|
129
130
|
# -----------------------------------------------------------------
|
|
@@ -433,6 +434,16 @@ class ICMD:
|
|
|
433
434
|
"""Return the active owner group as a full group dict."""
|
|
434
435
|
return self._groups.owner_group
|
|
435
436
|
|
|
437
|
+
@property
|
|
438
|
+
def has_owner_group(self) -> bool:
|
|
439
|
+
"""Whether this client already holds an owner group.
|
|
440
|
+
|
|
441
|
+
Set by the ``owner_group`` constructor argument, by
|
|
442
|
+
:meth:`set_owner_group`, or by the first request that resolved one.
|
|
443
|
+
Reading it never resolves a group, so it never prompts.
|
|
444
|
+
"""
|
|
445
|
+
return self._groups.has_owner_group
|
|
446
|
+
|
|
436
447
|
# -----------------------------------------------------------------
|
|
437
448
|
# Group management
|
|
438
449
|
# -----------------------------------------------------------------
|
|
@@ -474,7 +485,8 @@ class ICMD:
|
|
|
474
485
|
----------
|
|
475
486
|
inject_group : bool
|
|
476
487
|
If ``True`` (default), automatically inject the active
|
|
477
|
-
``owner_group`` as a query parameter
|
|
488
|
+
``owner_group`` as a query parameter; an ``owner_group`` already
|
|
489
|
+
in ``params`` is sent as given. ``False`` suppresses that
|
|
478
490
|
injection: group-scoped detail routes then fall back to the
|
|
479
491
|
caller's server-side ``current_group``, while other endpoints
|
|
480
492
|
apply their own scoping. It does not request a cross-group
|
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.11.0"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: icmd-python
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.11.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,21 +1,22 @@
|
|
|
1
1
|
icmd/__init__.py,sha256=-2i4tJ69UAb547_LPnAVrglNc1ZRRTce5Ju2BoKzaDc,3109
|
|
2
|
-
icmd/client.py,sha256=
|
|
2
|
+
icmd/client.py,sha256=0mHPuWCvfv-urlTRqOvjmaBwFNt09XR1nmr-4QSRk3Q,23098
|
|
3
3
|
icmd/compose.py,sha256=-xkfVsqvNejYnaxvddlidNpzLE1tZ59Qdj8DZq1yENQ,7089
|
|
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=cpptu6yAKjujkKCECVRXYkw3SmUpyBiTvJvB3Mmcu5s,23
|
|
8
8
|
icmd/_vendor/__init__.py,sha256=VFA4EQd2l76sxgkHPUpgzZavFF6kFdpq9v71AZsPIdQ,579
|
|
9
9
|
icmd/_vendor/assembler.py,sha256=2KpvpIARfcM2H1mE3ajKT6ukaEnE5cKztSySpsSq8ZA,5776
|
|
10
|
-
icmd/apps/__init__.py,sha256=
|
|
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
|
|
@@ -27,12 +28,12 @@ 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
38
|
icmd/core/advisory.py,sha256=9zB2Ew5RKtwwDKTYF3nPYSuv5SnHCEthriYfTr6Tzko,22707
|
|
38
39
|
icmd/core/auth.py,sha256=bG6eLbSkmyDWLyprx7NJMDwLTGsOIrsRdBZ3f6vz1vk,29480
|
|
@@ -41,12 +42,12 @@ icmd/core/contracts.py,sha256=uHybwSxBHV4l6M4ho4ECRKitXm4lMbBroFhGxim0V0I,10690
|
|
|
41
42
|
icmd/core/credentials.py,sha256=39vyoFH1_MrzD9TpTh4FVPhWbrY5X35zGCS22KeTi_8,46835
|
|
42
43
|
icmd/core/display.py,sha256=RkgJhr2Bfw3-ZjGvE100ddD6oyAAUUAJfBAy6DvX5ik,13060
|
|
43
44
|
icmd/core/exceptions.py,sha256=Lv9zPMqZqZW8O-QqZ8AThF2u9xcGv-yfHWk1Vl6FKeA,12663
|
|
44
|
-
icmd/core/groups.py,sha256=
|
|
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.11.0.dist-info/licenses/LICENSE,sha256=644lGvCmDFa5hQl6xdhaYk3o-1oWqPXAIiiQlvqeZdE,1028
|
|
49
|
+
icmd_python-3.11.0.dist-info/METADATA,sha256=47PYNTfR1-9e39BnnlAHFJQSOOfoWcQy6r2OHGcs59c,5358
|
|
50
|
+
icmd_python-3.11.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
51
|
+
icmd_python-3.11.0.dist-info/entry_points.txt,sha256=CFX537PlyyPQL3Q_3TKUFeiiDSkOAboqaLEy9AIEwVU,39
|
|
52
|
+
icmd_python-3.11.0.dist-info/top_level.txt,sha256=olzM6Z_QEwg7RrFVOnkgO0I2Ps2m7vhSqYQZYeiawcw,5
|
|
53
|
+
icmd_python-3.11.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|