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 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] = {"composition": deepcopy(dict(composition))}
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("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/__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 ``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
@@ -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. ``False`` suppresses that
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 envelope half of the check -- which reads 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
- schema_key = json.dumps(
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 per-model inputs and return non-blocking findings.
568
-
569
- Two independent halves, reported in that order. The envelope check reads
570
- the document alone: a model deferring to a global the job never defines is
571
- reported here rather than failing worker-side once the job is claimed, and
572
- needs no contract to say so. Contract validation is the other half, and if
573
- any model's contract cannot be fetched it contributes one ``"check
574
- skipped"`` finding instead of its own, so partial contract output cannot be
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
- The envelope findings survive that skip -- a pre-contract or unreachable
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. A skip puts that finding first.
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; that
499
- suppresses the server-config fetch as well as the display. Authenticators
500
- that do not declare the optional flag display the banner.
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. Both
71
- caches are memory-only and :meth:`clear` drops them together.
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 and the classes built from them."""
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
- 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.14.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icmd-python
3
- Version: 3.10.0
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` — 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,52 +1,53 @@
1
- icmd/__init__.py,sha256=-2i4tJ69UAb547_LPnAVrglNc1ZRRTce5Ju2BoKzaDc,3109
2
- icmd/client.py,sha256=eXWJuoHX2cv7B30Vha5aCAOoR5nE-y8u4d2V0d-_SU4,22600
3
- icmd/compose.py,sha256=-xkfVsqvNejYnaxvddlidNpzLE1tZ59Qdj8DZq1yENQ,7089
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=iKJAvc5i0UTDDSSefTGL0Tq-kWQ4S3OJJgvyaQfQNF8,23
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=2KpvpIARfcM2H1mE3ajKT6ukaEnE5cKztSySpsSq8ZA,5776
10
- icmd/apps/__init__.py,sha256=ak2ch1UU-qaiyOT1eCdRwqSI0Avhzyw5DLkw-U2ivQQ,659
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=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
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=mBpAlW1BCKglPzdDffTc4uLdr_2Ri7qnVC2I-Q9FjEA,7395
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=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
- icmd/core/advisory.py,sha256=9zB2Ew5RKtwwDKTYF3nPYSuv5SnHCEthriYfTr6Tzko,22707
38
- icmd/core/auth.py,sha256=bG6eLbSkmyDWLyprx7NJMDwLTGsOIrsRdBZ3f6vz1vk,29480
39
- icmd/core/constants.py,sha256=P3a01sDt5WmGYqkp5hu4UqWbMgfp50o6rBV3q9jU4yA,2339
40
- icmd/core/contracts.py,sha256=uHybwSxBHV4l6M4ho4ECRKitXm4lMbBroFhGxim0V0I,10690
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=Lv9zPMqZqZW8O-QqZ8AThF2u9xcGv-yfHWk1Vl6FKeA,12663
44
- icmd/core/groups.py,sha256=OFbQce42DLDK0rDAdMbh8H7imNPAkBJMl7o7tbKQ4Iw,7211
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=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.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,,
@@ -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