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 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("get", f"{_MESSAGE_PATH}/{message_uuid}", inject_group=False),
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 = False,
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. For jobs specifically, the
227
- Studio action ``studio/jobs/{uuid}/accessible-group/`` answers which
228
- of your groups can ordinarily see a job; it walks plain project
229
- access, so it performs no admin widening.
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
- inject_group : bool
238
- When ``True``, pin the lookup to the client's active
239
- ``owner_group`` (resolving it if needed) instead of leaving it
240
- to the caller's ``current_group``.
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*) and skipped for *detail* actions — the
272
- same convention :meth:`list` and :meth:`get` follow.
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. Group injection
332
- is skipped — the UUID fully identifies the target.
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("patch", endpoint, inject_group=False, json=data),
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("put", endpoint, inject_group=False, json=data),
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("delete", endpoint, inject_group=False),
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=False), endpoint
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=False), endpoint
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("delete", endpoint, inject_group=False), endpoint
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=False), endpoint
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=False), endpoint
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("get", endpoint, inject_group=False, params=params or None),
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("get", endpoint, inject_group=False, params=params or None),
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("delete", endpoint, inject_group=False),
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("delete", endpoint, inject_group=False),
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=False,
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(uuid, params={"admin": "true"} if admin else None, inject_group=admin)
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 ``log`` — and ``action --id`` when ``--param`` carried no
374
- #: ``owner_group`` — must say about scope: both call
375
- #: ``request_json(..., inject_group=False)``, so naming a group here would name
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
- #: ``--param`` did not already carry it: telling callers to re-run the flag
982
- #: they just typed is the wild-goose chase :func:`_explaining_404` exists to end.
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 explicitly — action sends "
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
- # ``action`` transmits no scope of its own, so ``--param`` is the whole
1025
- # truth about one: report the group the caller actually sent, and offer
1026
- # only the half they left out.
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="Skip the confirmation prompt for a destructive (--method delete) action.",
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
- if opts["method"] == "delete":
1090
- target = "/".join(p for p in (path, opts["uuid"], opts["name"]) if p)
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 DELETE {target}? This may be irreversible.",
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
- resource = _get_client_and_resource(ctx, path)
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
- remedies = (remedy, _accessible_group_remedy(ctx, resource_id))
1245
- with _explaining_404(resource_id, scope=_NO_GROUP_SENT, remedies=remedies):
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=False, params=params or None
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("get", endpoint, inject_group=False, params=params or None)
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. ``False`` suppresses that
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
- No recursion risk: :meth:`_resolve_owner_group` fetches groups via
127
- ``Session.request_json`` directly, which does not call back into
128
- this method.
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
- group = self.owner_group
131
- params = kwargs.get("params") or {}
132
- params["owner_group"] = group["id"]
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
@@ -69,6 +69,11 @@ class ResourceClient(Protocol):
69
69
 
70
70
  contracts: ContractClient
71
71
 
72
+ @property
73
+ def has_owner_group(self) -> bool:
74
+ """Whether an owner group is already held, without resolving one."""
75
+ ...
76
+
72
77
  def request_json(
73
78
  self,
74
79
  method: HTTPMethod,
icmd/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "3.10.0"
1
+ __version__ = "3.11.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icmd-python
3
- Version: 3.10.0
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` — the API
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=eXWJuoHX2cv7B30Vha5aCAOoR5nE-y8u4d2V0d-_SU4,22600
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=iKJAvc5i0UTDDSSefTGL0Tq-kWQ4S3OJJgvyaQfQNF8,23
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=ak2ch1UU-qaiyOT1eCdRwqSI0Avhzyw5DLkw-U2ivQQ,659
10
+ icmd/apps/__init__.py,sha256=Bg6rR2Zt38OHdBBwGKBzSqbsDW8chVM9KwjZ4IGODXE,712
11
11
  icmd/apps/account.py,sha256=BWDO0Zysv0ygLVhbeIk82BjsvuDVb_vBdu6jqBPw3tc,14557
12
- icmd/apps/agent.py,sha256=MmWuH1sYqo-hOhNNuTwh930H_BFXs7oMP0QgyzaC0Iw,8387
13
- icmd/apps/base.py,sha256=SHqvtkHTxNIYRZMV2BMG-SVTWl_3iPm4WVINUbjmGN4,18983
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=ckjS8Uz2hVyTqsTPF14g7paKMHsaRg3jZ90lZLdTz-o,66924
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=9Bh6DaZ7oDxrKxWsZxSVCBxKFLYd_3dASTaYhAhR6iQ,18224
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=srDoAMF5kiMxph0vUKpo1urRt7CEaH7Lgrb1TiksWmk,63707
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=zUMbuDXLYO442J54Ivns_exFxYRwjnefA3zMrd1vaG4,16697
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=OFbQce42DLDK0rDAdMbh8H7imNPAkBJMl7o7tbKQ4Iw,7211
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=UmpcixYEhBYgDJyd0ijXKhCXTK426XzbNzTCzSDM9WM,60759
47
- icmd_python-3.10.0.dist-info/licenses/LICENSE,sha256=644lGvCmDFa5hQl6xdhaYk3o-1oWqPXAIiiQlvqeZdE,1028
48
- icmd_python-3.10.0.dist-info/METADATA,sha256=oZpNJbpbNkS1w1Jx46TR3p3x2KMeySULtmpFnIEX-lE,5339
49
- icmd_python-3.10.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
50
- icmd_python-3.10.0.dist-info/entry_points.txt,sha256=CFX537PlyyPQL3Q_3TKUFeiiDSkOAboqaLEy9AIEwVU,39
51
- icmd_python-3.10.0.dist-info/top_level.txt,sha256=olzM6Z_QEwg7RrFVOnkgO0I2Ps2m7vhSqYQZYeiawcw,5
52
- icmd_python-3.10.0.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5