icmd-python 1.7.1__py3-none-any.whl → 3.3.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.
Files changed (55) hide show
  1. icmd/__init__.py +97 -0
  2. icmd/_vendor/__init__.py +23 -0
  3. icmd/_vendor/assembler.py +135 -0
  4. icmd/apps/__init__.py +15 -7
  5. icmd/apps/account.py +384 -0
  6. icmd/apps/agent.py +216 -0
  7. icmd/apps/base.py +376 -131
  8. icmd/apps/constants.py +25 -0
  9. icmd/apps/documents.py +97 -0
  10. icmd/apps/jobs.py +822 -0
  11. icmd/apps/navigation.py +84 -0
  12. icmd/apps/results.py +182 -0
  13. icmd/apps/studio.py +67 -0
  14. icmd/apps/suite.py +67 -0
  15. icmd/apps/types.py +11 -9
  16. icmd/apps/workspace.py +45 -18
  17. icmd/cli/__init__.py +83 -37
  18. icmd/cli/account.py +387 -0
  19. icmd/cli/auth.py +3 -0
  20. icmd/cli/auth_status.py +300 -0
  21. icmd/cli/client.py +38 -3
  22. icmd/cli/commands.py +1168 -61
  23. icmd/cli/diagnostics.py +66 -0
  24. icmd/cli/domain.py +9 -9
  25. icmd/cli/links.py +287 -0
  26. icmd/cli/output.py +12 -3
  27. icmd/cli/registry.py +326 -54
  28. icmd/client.py +334 -35
  29. icmd/compose.py +159 -0
  30. icmd/core/__init__.py +1 -1
  31. icmd/core/advisory.py +541 -0
  32. icmd/core/auth.py +179 -60
  33. icmd/core/constants.py +24 -3
  34. icmd/core/contracts.py +242 -0
  35. icmd/core/credentials.py +976 -79
  36. icmd/core/display.py +47 -16
  37. icmd/core/exceptions.py +142 -1
  38. icmd/core/groups.py +17 -3
  39. icmd/core/model_classes.py +977 -0
  40. icmd/core/session.py +921 -62
  41. icmd/links.py +143 -0
  42. icmd/py.typed +0 -0
  43. icmd/types.py +1885 -0
  44. icmd/version.py +1 -1
  45. icmd_python-3.3.0.dist-info/METADATA +128 -0
  46. icmd_python-3.3.0.dist-info/RECORD +50 -0
  47. {icmd_python-1.7.1.dist-info → icmd_python-3.3.0.dist-info}/WHEEL +1 -1
  48. icmd_python-3.3.0.dist-info/licenses/LICENSE +18 -0
  49. icmd/apps/design.py +0 -35
  50. icmd/apps/fatigue.py +0 -33
  51. icmd/core/slim_packages.py +0 -376
  52. icmd_python-1.7.1.dist-info/METADATA +0 -21
  53. icmd_python-1.7.1.dist-info/RECORD +0 -30
  54. {icmd_python-1.7.1.dist-info → icmd_python-3.3.0.dist-info}/entry_points.txt +0 -0
  55. {icmd_python-1.7.1.dist-info → icmd_python-3.3.0.dist-info}/top_level.txt +0 -0
icmd/__init__.py CHANGED
@@ -1,15 +1,112 @@
1
1
  """Python client for the ICMD® API."""
2
2
 
3
3
  from .client import ICMD
4
+ from .compose import ModelInput, compose_input_dict
5
+ from .core.advisory import AdvisoryFinding, ContractProvider, check_input_dict
6
+ from .core.contracts import ContractClient, ContractTransport
4
7
  from .core.exceptions import (
5
8
  ICMDAuthenticationError,
6
9
  ICMDConnectionError,
10
+ ICMDContractUnavailableError,
11
+ ICMDRefreshNetworkError,
12
+ ICMDRefreshRateLimitedError,
13
+ ICMDRefreshResponseError,
14
+ ICMDRefreshTokenInvalidError,
15
+ ICMDRefreshUnavailableError,
16
+ ICMDRequestError,
17
+ ICMDTokenRefreshError,
7
18
  ICMDValidationError,
8
19
  )
20
+ from .core.model_classes import ContractFallbackWarning
21
+ from .links import KIND_PATHS, link_for
22
+ from .types import (
23
+ CalibrationAlgorithm,
24
+ CalibrationOutput,
25
+ CalibrationSampling,
26
+ Component,
27
+ Composition,
28
+ CompositionalVariable,
29
+ Database,
30
+ DatasetCompositionalVariable,
31
+ DatasetSource,
32
+ DatasetVariable,
33
+ DesignSpace,
34
+ FromCalibration,
35
+ FromComposition,
36
+ FromDataset,
37
+ FromModel,
38
+ FromOptimization,
39
+ FromVariable,
40
+ GridDistribution,
41
+ GridSampling,
42
+ GridVariable,
43
+ LatinHypercubeSampling,
44
+ ModelNode,
45
+ MonteCarloSampling,
46
+ SensitivitySampling,
47
+ SeriesColumnMetadata,
48
+ SeriesDatasetSource,
49
+ SeriesDatasetVariable,
50
+ TrainedModel,
51
+ UniformDistribution,
52
+ UniformVariable,
53
+ UseGlobal,
54
+ Variable,
55
+ )
9
56
 
10
57
  __all__ = [
11
58
  "ICMD",
59
+ "KIND_PATHS",
60
+ "AdvisoryFinding",
61
+ "CalibrationAlgorithm",
62
+ "CalibrationOutput",
63
+ "CalibrationSampling",
64
+ "Component",
65
+ "Composition",
66
+ "CompositionalVariable",
67
+ "ContractClient",
68
+ "ContractFallbackWarning",
69
+ "ContractProvider",
70
+ "ContractTransport",
71
+ "Database",
72
+ "DatasetCompositionalVariable",
73
+ "DatasetSource",
74
+ "DatasetVariable",
75
+ "DesignSpace",
76
+ "FromCalibration",
77
+ "FromComposition",
78
+ "FromDataset",
79
+ "FromModel",
80
+ "FromOptimization",
81
+ "FromVariable",
82
+ "GridDistribution",
83
+ "GridSampling",
84
+ "GridVariable",
12
85
  "ICMDAuthenticationError",
13
86
  "ICMDConnectionError",
87
+ "ICMDContractUnavailableError",
88
+ "ICMDRefreshNetworkError",
89
+ "ICMDRefreshRateLimitedError",
90
+ "ICMDRefreshResponseError",
91
+ "ICMDRefreshTokenInvalidError",
92
+ "ICMDRefreshUnavailableError",
93
+ "ICMDRequestError",
94
+ "ICMDTokenRefreshError",
14
95
  "ICMDValidationError",
96
+ "LatinHypercubeSampling",
97
+ "ModelInput",
98
+ "ModelNode",
99
+ "MonteCarloSampling",
100
+ "SensitivitySampling",
101
+ "SeriesColumnMetadata",
102
+ "SeriesDatasetSource",
103
+ "SeriesDatasetVariable",
104
+ "TrainedModel",
105
+ "UniformDistribution",
106
+ "UniformVariable",
107
+ "UseGlobal",
108
+ "Variable",
109
+ "check_input_dict",
110
+ "compose_input_dict",
111
+ "link_for",
15
112
  ]
@@ -0,0 +1,23 @@
1
+ """Reviewed, model-agnostic code maintained inside the ICMD SDK.
2
+
3
+ Purpose-built package facade; this file was not copied from qt-designer.
4
+ Reference repository: qt-designer
5
+ Reference commit: 9352480f33face07ff42a558c709c18c8e142f85
6
+ Reference path: src/qt_designer/schemas/mixins/model_mixin.py
7
+ """
8
+
9
+ from .assembler import (
10
+ SCENARIO_KEY,
11
+ SOURCE_KEYS,
12
+ assemble_model_inputs,
13
+ assemble_model_node,
14
+ build_design_document,
15
+ )
16
+
17
+ __all__ = [
18
+ "SCENARIO_KEY",
19
+ "SOURCE_KEYS",
20
+ "assemble_model_inputs",
21
+ "assemble_model_node",
22
+ "build_design_document",
23
+ ]
@@ -0,0 +1,135 @@
1
+ """Model-agnostic helpers for assembling the ICMD job envelope.
2
+
3
+ Purpose-built reference implementation; this file was not copied from qt-designer.
4
+
5
+ Reference repository: qt-designer
6
+ Reference commit: 9352480f33face07ff42a558c709c18c8e142f85
7
+ Reference paths:
8
+ - src/qt_designer/schemas/input_dict.py (top-level envelope shape)
9
+ - src/qt_designer/schemas/helpers/designer_input_helpers.py (source-entry selection)
10
+ - src/qt_designer/schemas/mixins/input_dict_builder_mixin.py (document composition)
11
+ - src/qt_designer/schemas/mixins/model_mixin.py (model-node and input-entry shapes)
12
+ """
13
+
14
+ from collections.abc import Mapping, Sequence
15
+ from copy import deepcopy
16
+ from typing import Any
17
+
18
+ __all__ = [
19
+ "SCENARIO_KEY",
20
+ "SOURCE_KEYS",
21
+ "assemble_model_inputs",
22
+ "assemble_model_node",
23
+ "build_design_document",
24
+ ]
25
+
26
+ #: The scenario discriminator this assembler emits into ``qt_model_inputs``.
27
+ #: Exported so consumers that must recognize it -- notably the advisory check,
28
+ #: which excludes it from contract validation -- bind to this module rather than
29
+ #: re-spelling the literal. Renaming it here must not leave a stale copy behind.
30
+ SCENARIO_KEY = "__meta_scenario"
31
+
32
+ REFERENCE_COMMIT = "9352480f33face07ff42a558c709c18c8e142f85"
33
+ REFERENCE_PATHS = (
34
+ "src/qt_designer/schemas/input_dict.py",
35
+ "src/qt_designer/schemas/helpers/designer_input_helpers.py",
36
+ "src/qt_designer/schemas/mixins/input_dict_builder_mixin.py",
37
+ "src/qt_designer/schemas/mixins/model_mixin.py",
38
+ )
39
+
40
+ # This is intentionally a closed wire-shape list, not a dependency on qt-models.
41
+ SOURCE_KEYS = frozenset(
42
+ {
43
+ "from_calibrated",
44
+ "from_calibration",
45
+ "from_composition",
46
+ "from_dataset",
47
+ "from_model",
48
+ "from_optimization",
49
+ "from_series_dataset",
50
+ "from_uncertainty",
51
+ "from_variable",
52
+ }
53
+ )
54
+
55
+
56
+ def assemble_model_inputs(inputs: Mapping[str, Any]) -> list[dict[str, Any]]:
57
+ """Convert a model input mapping into ``key``/``from_*`` wire entries."""
58
+ entries = []
59
+ for key, value in inputs.items():
60
+ source = "from_user"
61
+ assembled_value = deepcopy(value)
62
+ if isinstance(value, dict):
63
+ matched_sources = [candidate for candidate in value if candidate in SOURCE_KEYS]
64
+ if len(matched_sources) > 1:
65
+ raise ValueError(
66
+ f"Multiple 'from_*' keys found: {matched_sources}. Only one is allowed."
67
+ )
68
+ if matched_sources and SCENARIO_KEY not in value:
69
+ source = matched_sources[0]
70
+ assembled_value = deepcopy(value[source])
71
+ elif matched_sources:
72
+ assembled_value = {
73
+ SCENARIO_KEY: deepcopy(value[SCENARIO_KEY]),
74
+ matched_sources[0]: deepcopy(value[matched_sources[0]]),
75
+ }
76
+ elif "use_global" in value:
77
+ if value["use_global"]:
78
+ assembled_value = "use_global"
79
+ else:
80
+ assembled_value.pop("use_global")
81
+ entries.append({"key": key, source: assembled_value})
82
+ return entries
83
+
84
+
85
+ def assemble_model_node(
86
+ name: str,
87
+ model_uuid: str,
88
+ inputs: Mapping[str, Any],
89
+ scenario: str | None = None,
90
+ node_id: str | None = None,
91
+ ) -> dict[str, Any]:
92
+ """Build one model workflow node without model lookup or validation.
93
+
94
+ ``node_id`` identifies this workflow instance. The catalog ``model_uuid``
95
+ remains in ``qt_model.uuid`` and is used only as the default node id.
96
+ """
97
+ model_inputs = deepcopy(dict(inputs))
98
+ if scenario is not None:
99
+ model_inputs[SCENARIO_KEY] = {"title": scenario}
100
+ return {
101
+ "id": model_uuid if node_id is None else node_id,
102
+ "qt_model": {"name": name, "uuid": model_uuid},
103
+ "qt_model_inputs": assemble_model_inputs(model_inputs),
104
+ }
105
+
106
+
107
+ def build_design_document(
108
+ composition: Mapping[str, Any],
109
+ variables: Mapping[str, Any] | None = None,
110
+ databases: Sequence[Mapping[str, Any]] | None = None,
111
+ engine: str | None = None,
112
+ ) -> dict[str, Any]:
113
+ """Build the model-independent design document skeleton.
114
+
115
+ ``databases`` and ``engine`` are the design-level values a model input
116
+ spelled ``use_global`` resolves against. Each is emitted only when it holds
117
+ a value: a consumer reads an empty list or an empty string as no value at
118
+ all, so an empty one is left out rather than written present-and-falsy.
119
+ Passing ``[]`` or ``""`` is therefore the same as passing nothing, and the
120
+ advisory reports the deferring model either way.
121
+ """
122
+ design_space = deepcopy(dict(variables)) if variables is not None else {}
123
+ design_space.setdefault("sampling_input", {"type": "grid"})
124
+ design_space.setdefault("compositional_variables", [])
125
+ design_space.setdefault("model_variables", [])
126
+ design_space.setdefault("dataset_variables", [])
127
+ design_space.setdefault("series_dataset_variables", [])
128
+ document: dict[str, Any] = {"composition": deepcopy(dict(composition))}
129
+ if databases:
130
+ document["databases"] = deepcopy(list(databases))
131
+ if engine:
132
+ document["engine"] = engine
133
+ document["design_space"] = design_space
134
+ document["model_workflow"] = {"models": []}
135
+ return document
icmd/apps/__init__.py CHANGED
@@ -1,18 +1,26 @@
1
- """Composition-based resource access for ICMD® API.
1
+ """Composition-based resource access for the ICMD® API.
2
2
 
3
3
  Provides a clean nested-attribute API::
4
4
 
5
- icmd = ICMD("icmd.questek.com")
6
- icmd.alloy_design.job.list(page=1, page_size=10)
5
+ icmd = ICMD("icmd.ai")
6
+ icmd.suite.databases.list()
7
+ icmd.studio.jobs.submit(input_dict, engine="pandat", folder="<uuid>")
7
8
  icmd.workspace.dataset.get("uuid-123")
9
+ icmd.agent.conversations.send_message("<uuid>", "Hi", wait=True)
8
10
  """
9
11
 
10
- from .design import DesignApp
11
- from .fatigue import FatigueApp
12
+ from .account import AccountApp
13
+ from .agent import AgentApp
14
+ from .constants import ProjectKind
15
+ from .studio import StudioApp
16
+ from .suite import SuiteApp
12
17
  from .workspace import WorkspaceApp
13
18
 
14
19
  __all__ = [
15
- "DesignApp",
16
- "FatigueApp",
20
+ "AccountApp",
21
+ "AgentApp",
22
+ "ProjectKind",
23
+ "StudioApp",
24
+ "SuiteApp",
17
25
  "WorkspaceApp",
18
26
  ]
icmd/apps/account.py ADDED
@@ -0,0 +1,384 @@
1
+ """Account resources — developer tokens, the current-user profile, and workers.
2
+
3
+ Wraps the user-facing slices of ``/api/v2/account/``::
4
+
5
+ icmd = ICMD("icmd.ai")
6
+ icmd.account.me()
7
+ tok = icmd.account.developer_tokens.generate(description="CI", lifetime_days=30)
8
+ print(tok["token"]) # shown only once — capture it now
9
+
10
+ These endpoints identify the caller by bearer token, so group injection is
11
+ skipped (they are not owner-group scoped) and never triggers interactive
12
+ group resolution.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from .base import ReadOnlyResource, _expect_dict
20
+
21
+ if TYPE_CHECKING:
22
+ from icmd.core.session import ResourceClient
23
+
24
+
25
+ class DeveloperTokens:
26
+ """Manage developer API tokens for non-interactive auth (CI, scripts, MCP).
27
+
28
+ A developer token is what :class:`~icmd.client.ICMD` accepts as
29
+ ``developer_token`` for headless use. **The plaintext token is returned
30
+ only by :meth:`generate`** — the backend never stores it and it cannot be
31
+ retrieved again, so capture it then. If lost, :meth:`revoke` it and
32
+ generate a new one. Generating requires the ``has_developer_access``
33
+ permission and is rate-limited server-side.
34
+ """
35
+
36
+ _GENERATE = "account/auth/developer/generate"
37
+ _TOKENS = "account/auth/developer/tokens"
38
+
39
+ def __init__(self, client: ResourceClient) -> None:
40
+ self._client = client
41
+
42
+ def generate(
43
+ self,
44
+ *,
45
+ description: str | None = None,
46
+ lifetime_days: int | None = None,
47
+ ) -> dict:
48
+ """Mint a new developer token (``POST .../developer/generate``).
49
+
50
+ Returns the JWT ``token`` (shown only once), its ``token_id``, and
51
+ expiration info.
52
+
53
+ Parameters
54
+ ----------
55
+ description : str | None
56
+ A human label for the token (max 200 chars), e.g. ``"CI"``.
57
+ lifetime_days : int | None
58
+ Validity window in days (server default 30, bounded by the
59
+ tenant's configured maximum).
60
+ """
61
+ body: dict[str, Any] = {}
62
+ if description is not None:
63
+ body["description"] = description
64
+ if lifetime_days is not None:
65
+ body["lifetime_days"] = lifetime_days
66
+ return _expect_dict(
67
+ self._client.request_json(
68
+ "post", self._GENERATE, inject_group=False, json=body or None
69
+ ),
70
+ self._GENERATE,
71
+ )
72
+
73
+ def list(self) -> dict | list[dict]:
74
+ """List your developer tokens' metadata (``GET .../developer/tokens``).
75
+
76
+ Never includes the plaintext token — only ids, descriptions, and
77
+ expiry.
78
+ """
79
+ return self._client.request_json("get", self._TOKENS, inject_group=False)
80
+
81
+ def revoke(self, token_id: str) -> dict:
82
+ """Revoke a developer token (``POST .../developer/tokens/{token_id}/revoke``)."""
83
+ endpoint = f"{self._TOKENS}/{token_id}/revoke"
84
+ return _expect_dict(
85
+ self._client.request_json("post", endpoint, inject_group=False), endpoint
86
+ )
87
+
88
+
89
+ class GroupUsers:
90
+ """Manage members of the groups you administer (``account/auth/group-users``).
91
+
92
+ Scope comes from your admin memberships server-side, not from a group
93
+ parameter: you see and edit only users in groups you administer. A
94
+ non-admin gets an **empty list, not a 403** — an empty result does not
95
+ prove the group is empty.
96
+
97
+ :meth:`update` accepts exactly four writable fields:
98
+
99
+ ``is_active`` : bool
100
+ **A GLOBAL login flag, not a group-membership flag.** Setting it to
101
+ ``False`` disables the user's login *everywhere*, not just in your
102
+ group; the server only checks that the target's groups are a subset of
103
+ the ones you administer before allowing it. This is almost never what
104
+ "remove someone from my group" means — use ``remove_from_group``.
105
+ ``first_name`` : str
106
+ The target's given name.
107
+ ``last_name`` : str
108
+ The target's family name.
109
+ ``remove_from_group`` : str
110
+ Drop the user from this group. **Takes the group NAME, not its ID** —
111
+ unlike the rest of the SDK, which accepts either. Passing an ID here
112
+ will not match.
113
+
114
+ Known backend gaps (pre-existing and backend-owned — documented so callers
115
+ are not surprised, not fixed here):
116
+
117
+ * **Removal does not fully revoke access.** The server removes the user
118
+ from the group's membership only; it never clears their *current group*
119
+ pointer, and nothing repairs it afterwards. A removed member may retain
120
+ group context until something else changes it.
121
+ * **Removing an admin leaves their admin rights.** A group's admin list is
122
+ tracked separately from its membership and is not coupled to it, so
123
+ removing an admin member does not revoke their admin powers.
124
+ * **Cross-group disclosure.** Responses list *all* of a target's
125
+ memberships, not just the ones you administer — a Group A admin can see
126
+ that the user also belongs to B and C.
127
+ * **Silent no-op updates.** The server filters the body to the four
128
+ writable fields *before* validating it, so a body of unknown or forbidden
129
+ keys (e.g. ``{"email": "x", "bogus": 1}``) validates as an *empty update*
130
+ and reports success. A typo silently changes nothing — verify the
131
+ returned object rather than trusting the status.
132
+ """
133
+
134
+ _PATH = "account/auth/group-users"
135
+
136
+ def __init__(self, client: ResourceClient) -> None:
137
+ self._client = client
138
+
139
+ def list(self, **params: Any) -> dict | list[dict]:
140
+ """List members of the groups you administer (``GET .../group-users``).
141
+
142
+ Parameters
143
+ ----------
144
+ **params
145
+ Query parameters forwarded to the API. The backend supports
146
+ ``search`` (name/email substring), ``active`` (bool, filter by the
147
+ global login flag), ``group`` (group name — restrict to one of your
148
+ administered groups), plus ``page`` / ``page_size``.
149
+
150
+ Returns
151
+ -------
152
+ dict | list[dict]
153
+ The paginated envelope, or a plain list on unpaginated responses.
154
+ **Empty for non-admins** rather than an error.
155
+ """
156
+ return self._client.request_json(
157
+ "get", self._PATH, inject_group=False, params=params or None
158
+ )
159
+
160
+ def get(self, uuid: str) -> dict:
161
+ """Get a single managed member (``GET .../group-users/{uuid}``).
162
+
163
+ Parameters
164
+ ----------
165
+ uuid : str
166
+ The target user's UUID.
167
+
168
+ Returns
169
+ -------
170
+ dict
171
+ The member object, including *all* of their group memberships —
172
+ not only the ones you administer.
173
+ """
174
+ endpoint = f"{self._PATH}/{uuid}"
175
+ return _expect_dict(
176
+ self._client.request_json("get", endpoint, inject_group=False), endpoint
177
+ )
178
+
179
+ def update(self, uuid: str, data: dict[str, Any]) -> dict:
180
+ """Update a managed member (``PATCH .../group-users/{uuid}``).
181
+
182
+ Parameters
183
+ ----------
184
+ uuid : str
185
+ The target user's UUID.
186
+ data : dict
187
+ Any of the four writable fields — ``is_active`` (**global** login
188
+ flag), ``first_name``, ``last_name``, ``remove_from_group``
189
+ (the group **NAME**, not its ID). See the class docstring: other
190
+ keys are silently dropped and the call still reports success.
191
+
192
+ Returns
193
+ -------
194
+ dict
195
+ The updated member object.
196
+ """
197
+ endpoint = f"{self._PATH}/{uuid}"
198
+ return _expect_dict(
199
+ self._client.request_json("patch", endpoint, inject_group=False, json=data),
200
+ endpoint,
201
+ )
202
+
203
+
204
+ class Invitations:
205
+ """Invite users to a group and manage pending invitations (``account/auth/invite``).
206
+
207
+ **An existing ICMD® user is added to the group immediately — no pending
208
+ invitation is created.** Despite the name, :meth:`create` only produces an
209
+ invitation for an email that has no account yet; for an existing account it
210
+ is an instant group-add, and :meth:`resend` / :meth:`cancel` will have
211
+ nothing to act on.
212
+ """
213
+
214
+ _PATH = "account/auth/invite"
215
+
216
+ def __init__(self, client: ResourceClient) -> None:
217
+ self._client = client
218
+
219
+ def list(self, **params: Any) -> dict | list[dict]:
220
+ """List invitations (``GET account/auth/invite``).
221
+
222
+ Parameters
223
+ ----------
224
+ **params
225
+ Query parameters forwarded to the API. The backend supports
226
+ ``group`` (group **name**) and ``status``.
227
+
228
+ Returns
229
+ -------
230
+ dict | list[dict]
231
+ The invitation records.
232
+ """
233
+ return self._client.request_json(
234
+ "get", self._PATH, inject_group=False, params=params or None
235
+ )
236
+
237
+ def create(self, *, email: str, group: str) -> dict:
238
+ """Invite a user to a group (``POST account/auth/invite/create/``).
239
+
240
+ Note the dedicated ``create/`` sub-path — this is *not* a POST to the
241
+ list path.
242
+
243
+ Parameters
244
+ ----------
245
+ email : str
246
+ The invitee's email address.
247
+ group : str
248
+ The group's **NAME, not its ID** — unlike the rest of the SDK,
249
+ which accepts either. Passing an ID will not match.
250
+
251
+ Returns
252
+ -------
253
+ dict
254
+ The created invitation, **or** the immediate-add result when
255
+ *email* belongs to an existing user (see the class docstring — no
256
+ pending invitation exists in that case).
257
+ """
258
+ endpoint = f"{self._PATH}/create"
259
+ return _expect_dict(
260
+ self._client.request_json(
261
+ "post", endpoint, inject_group=False, json={"email": email, "group": group}
262
+ ),
263
+ endpoint,
264
+ )
265
+
266
+ def resend(self, uuid: str) -> dict:
267
+ """Resend a pending invitation email (``POST account/auth/invite/{uuid}/resend/``).
268
+
269
+ Parameters
270
+ ----------
271
+ uuid : str
272
+ The invitation's UUID.
273
+
274
+ Returns
275
+ -------
276
+ dict
277
+ The resend result.
278
+ """
279
+ endpoint = f"{self._PATH}/{uuid}/resend"
280
+ return _expect_dict(
281
+ self._client.request_json("post", endpoint, inject_group=False), endpoint
282
+ )
283
+
284
+ def cancel(self, uuid: str) -> dict:
285
+ """Cancel a pending invitation (``DELETE account/auth/invite/{uuid}/``).
286
+
287
+ Parameters
288
+ ----------
289
+ uuid : str
290
+ The invitation's UUID.
291
+
292
+ Returns
293
+ -------
294
+ dict
295
+ Empty on the backend's ``204 No Content``.
296
+ """
297
+ endpoint = f"{self._PATH}/{uuid}"
298
+ return _expect_dict(
299
+ self._client.request_json("delete", endpoint, inject_group=False), endpoint
300
+ )
301
+
302
+
303
+ class AccountApp:
304
+ """Account resources — profile, developer tokens, workers, and group admin.
305
+
306
+ :attr:`developer_tokens` manages non-interactive auth tokens;
307
+ :attr:`workers` lists the group's workers (read-only); :attr:`group_users`
308
+ and :attr:`invitations` are the group-admin surface; the methods expose the
309
+ current-user profile and group detail.
310
+ """
311
+
312
+ __slots__ = ("_client", "developer_tokens", "group_users", "invitations", "workers")
313
+
314
+ def __init__(self, client: ResourceClient) -> None:
315
+ self._client = client
316
+ self.developer_tokens = DeveloperTokens(client)
317
+ self.group_users = GroupUsers(client)
318
+ self.invitations = Invitations(client)
319
+ self.workers = ReadOnlyResource(client, "account/workers")
320
+
321
+ def group(self, pk: int | str, *, admin: bool = False) -> dict:
322
+ """Get a single group by ID (``GET account/groups/{pk}``).
323
+
324
+ .. warning::
325
+ *pk* is the group's **integer ID — not a UUID**, unlike every other
326
+ getter in the SDK. The backend looks the group up by primary key
327
+ and returns ``400 Invalid group ID.`` for anything non-numeric.
328
+ Use :meth:`~icmd.client.ICMD.get_groups` to find the ID.
329
+
330
+ .. warning::
331
+ ``admin=True`` **degrades silently** when you are not an admin of
332
+ the group. Unlike the ``admin=true`` list-widening surface — which
333
+ fails closed with a ``403`` — this one checks the parameter *and*
334
+ real admin membership, then quietly returns the simplified payload
335
+ with ``capacity_info: None`` and **HTTP 200, no error**. The only
336
+ way to detect it is ``result.get("capacity_info") is None`` (use
337
+ ``.get`` — a plain subscript would raise if a future payload omits
338
+ the key rather than sending it as ``None``).
339
+
340
+ Parameters
341
+ ----------
342
+ pk : int | str
343
+ The group's integer ID (a numeric string is accepted).
344
+ admin : bool
345
+ When ``True``, request the admin-expanded representation (full
346
+ member list plus ``capacity_info``). Silently ignored if you do
347
+ not administer the group — see above.
348
+
349
+ Returns
350
+ -------
351
+ dict
352
+ The group object. With *admin* honoured it carries the expanded
353
+ member roster and a populated ``capacity_info``.
354
+ """
355
+ endpoint = f"account/groups/{pk}"
356
+ params = {"admin": "true"} if admin else None
357
+ return _expect_dict(
358
+ self._client.request_json("get", endpoint, inject_group=False, params=params),
359
+ endpoint,
360
+ )
361
+
362
+ def me(self) -> dict:
363
+ """Get the current authenticated user (``GET account/user``)."""
364
+ return _expect_dict(
365
+ self._client.request_json("get", "account/user", inject_group=False), "account/user"
366
+ )
367
+
368
+ def update_me(self, data: dict[str, Any]) -> dict:
369
+ """Update the current user's profile (``PATCH account/user/patch``)."""
370
+ endpoint = "account/user/patch"
371
+ return _expect_dict(
372
+ self._client.request_json("patch", endpoint, inject_group=False, json=data), endpoint
373
+ )
374
+
375
+ def update_password(self, data: dict[str, Any]) -> dict:
376
+ """Change the current user's password (``PATCH account/user/update_password``).
377
+
378
+ *data* carries the password-change fields (e.g. ``old_password`` /
379
+ ``new_password``) as required by the backend.
380
+ """
381
+ endpoint = "account/user/update_password"
382
+ return _expect_dict(
383
+ self._client.request_json("patch", endpoint, inject_group=False, json=data), endpoint
384
+ )