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.
- icmd/__init__.py +97 -0
- icmd/_vendor/__init__.py +23 -0
- icmd/_vendor/assembler.py +135 -0
- icmd/apps/__init__.py +15 -7
- icmd/apps/account.py +384 -0
- icmd/apps/agent.py +216 -0
- icmd/apps/base.py +376 -131
- icmd/apps/constants.py +25 -0
- icmd/apps/documents.py +97 -0
- icmd/apps/jobs.py +822 -0
- icmd/apps/navigation.py +84 -0
- icmd/apps/results.py +182 -0
- icmd/apps/studio.py +67 -0
- icmd/apps/suite.py +67 -0
- icmd/apps/types.py +11 -9
- icmd/apps/workspace.py +45 -18
- icmd/cli/__init__.py +83 -37
- icmd/cli/account.py +387 -0
- icmd/cli/auth.py +3 -0
- icmd/cli/auth_status.py +300 -0
- icmd/cli/client.py +38 -3
- icmd/cli/commands.py +1168 -61
- icmd/cli/diagnostics.py +66 -0
- icmd/cli/domain.py +9 -9
- icmd/cli/links.py +287 -0
- icmd/cli/output.py +12 -3
- icmd/cli/registry.py +326 -54
- icmd/client.py +334 -35
- icmd/compose.py +159 -0
- icmd/core/__init__.py +1 -1
- icmd/core/advisory.py +541 -0
- icmd/core/auth.py +179 -60
- icmd/core/constants.py +24 -3
- icmd/core/contracts.py +242 -0
- icmd/core/credentials.py +976 -79
- icmd/core/display.py +47 -16
- icmd/core/exceptions.py +142 -1
- icmd/core/groups.py +17 -3
- icmd/core/model_classes.py +977 -0
- icmd/core/session.py +921 -62
- icmd/links.py +143 -0
- icmd/py.typed +0 -0
- icmd/types.py +1885 -0
- icmd/version.py +1 -1
- icmd_python-3.3.0.dist-info/METADATA +128 -0
- icmd_python-3.3.0.dist-info/RECORD +50 -0
- {icmd_python-1.7.1.dist-info → icmd_python-3.3.0.dist-info}/WHEEL +1 -1
- icmd_python-3.3.0.dist-info/licenses/LICENSE +18 -0
- icmd/apps/design.py +0 -35
- icmd/apps/fatigue.py +0 -33
- icmd/core/slim_packages.py +0 -376
- icmd_python-1.7.1.dist-info/METADATA +0 -21
- icmd_python-1.7.1.dist-info/RECORD +0 -30
- {icmd_python-1.7.1.dist-info → icmd_python-3.3.0.dist-info}/entry_points.txt +0 -0
- {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
|
]
|
icmd/_vendor/__init__.py
ADDED
|
@@ -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.
|
|
6
|
-
icmd.
|
|
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 .
|
|
11
|
-
from .
|
|
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
|
-
"
|
|
16
|
-
"
|
|
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
|
+
)
|