icmd-python 3.11.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 +2 -0
- icmd/_vendor/assembler.py +10 -2
- icmd/cli/__init__.py +2 -0
- icmd/client.py +61 -2
- icmd/compose.py +9 -3
- icmd/core/advisory.py +115 -21
- icmd/core/auth.py +3 -6
- icmd/core/constants.py +9 -0
- icmd/core/contracts.py +45 -3
- icmd/core/exceptions.py +9 -0
- icmd/version.py +1 -1
- {icmd_python-3.11.0.dist-info → icmd_python-3.14.0.dist-info}/METADATA +1 -1
- {icmd_python-3.11.0.dist-info → icmd_python-3.14.0.dist-info}/RECORD +17 -17
- {icmd_python-3.11.0.dist-info → icmd_python-3.14.0.dist-info}/WHEEL +0 -0
- {icmd_python-3.11.0.dist-info → icmd_python-3.14.0.dist-info}/entry_points.txt +0 -0
- {icmd_python-3.11.0.dist-info → icmd_python-3.14.0.dist-info}/licenses/LICENSE +0 -0
- {icmd_python-3.11.0.dist-info → icmd_python-3.14.0.dist-info}/top_level.txt +0 -0
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] = {
|
|
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/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/client.py
CHANGED
|
@@ -8,6 +8,7 @@ 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
13
|
from .apps import AccountApp, AgentApp, CommentsApp, StudioApp, SuiteApp, WorkspaceApp
|
|
13
14
|
from .apps.base import _expect_dict
|
|
@@ -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"
|
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
|
|
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
|
-
|
|
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
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
499
|
-
|
|
500
|
-
|
|
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.
|
|
71
|
-
|
|
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
|
|
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/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "3.
|
|
1
|
+
__version__ = "3.14.0"
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
icmd/__init__.py,sha256
|
|
2
|
-
icmd/client.py,sha256=
|
|
3
|
-
icmd/compose.py,sha256
|
|
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=
|
|
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=
|
|
9
|
+
icmd/_vendor/assembler.py,sha256=3mOONZyT_J2WOVs06xofoxv0nSjBgSfPFV3nj-sUZGw,6176
|
|
10
10
|
icmd/apps/__init__.py,sha256=Bg6rR2Zt38OHdBBwGKBzSqbsDW8chVM9KwjZ4IGODXE,712
|
|
11
11
|
icmd/apps/account.py,sha256=BWDO0Zysv0ygLVhbeIk82BjsvuDVb_vBdu6jqBPw3tc,14557
|
|
12
12
|
icmd/apps/agent.py,sha256=EasQ0ZHbBbqXauAcoSym-eK2TOFDbdd2XGJPnKYBx6k,8467
|
|
@@ -23,7 +23,7 @@ icmd/apps/suite.py,sha256=1XT8aKPn15W-bwBdOz-JV3Wrmr_JOKyrMEC6--ljj9A,3907
|
|
|
23
23
|
icmd/apps/transfer.py,sha256=UVVEOmYynLzRtdTO6dbUBm6om6oHgmnspuKjACp0arE,36736
|
|
24
24
|
icmd/apps/types.py,sha256=jOzykkXWj9e_lk805_ABCC-sWBwIpJGN3HEb2AyQ9vw,1290
|
|
25
25
|
icmd/apps/workspace.py,sha256=BXCOLQo5BMu-okW2Ink8L_FPhU8r7OWy2jDsvilU3Oo,3055
|
|
26
|
-
icmd/cli/__init__.py,sha256=
|
|
26
|
+
icmd/cli/__init__.py,sha256=KiXdv_n5SOPqBxtTFQSnfqMf-T1ScxuOrbdkB_GUHp8,7447
|
|
27
27
|
icmd/cli/account.py,sha256=equTAGWR3NCCMYEPTw4IZKHEtBEdGD8F4Pp8cfiSNoU,13859
|
|
28
28
|
icmd/cli/auth.py,sha256=UFpEf-lLlkbphivgBfd9NxAEqHcCsPmEl-DXet-NStg,3317
|
|
29
29
|
icmd/cli/auth_status.py,sha256=6wkI3Q0c-t8DBOfuPaN_xXEUcL9HLZm1jX4lQ76X3Ag,13505
|
|
@@ -35,19 +35,19 @@ icmd/cli/links.py,sha256=7CMhKUJ-qKQmk2I-KCdn6oF06cdCq0v5jQDGU7gEJ30,14039
|
|
|
35
35
|
icmd/cli/output.py,sha256=-TKZ8fJYCUhh7qHRNAZyFYFaXcMDqm4NDR2qFfErg5A,1190
|
|
36
36
|
icmd/cli/registry.py,sha256=MEkqcu2b6ZjfmE2L2L062PxTjOTjlVEyYSycTv_tq30,17577
|
|
37
37
|
icmd/core/__init__.py,sha256=45q0vlUao61rG6ugufGTRtoDeBTDcscU5dLAMluDSBA,225
|
|
38
|
-
icmd/core/advisory.py,sha256=
|
|
39
|
-
icmd/core/auth.py,sha256=
|
|
40
|
-
icmd/core/constants.py,sha256=
|
|
41
|
-
icmd/core/contracts.py,sha256=
|
|
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
|
|
42
42
|
icmd/core/credentials.py,sha256=39vyoFH1_MrzD9TpTh4FVPhWbrY5X35zGCS22KeTi_8,46835
|
|
43
43
|
icmd/core/display.py,sha256=RkgJhr2Bfw3-ZjGvE100ddD6oyAAUUAJfBAy6DvX5ik,13060
|
|
44
|
-
icmd/core/exceptions.py,sha256=
|
|
44
|
+
icmd/core/exceptions.py,sha256=c88w5neJU7ohm5iEboA29jFaUfZ8EfrxmiO9rornTTw,13035
|
|
45
45
|
icmd/core/groups.py,sha256=BEwpBEe60l_8HpnsIvLPC3EhLSXnv-qyo-OZIyP0sWI,7585
|
|
46
46
|
icmd/core/model_classes.py,sha256=uuhJwh_AZ69eOp8SoWYo0MM5rNwA_a-vkxhrIwFasds,40372
|
|
47
47
|
icmd/core/session.py,sha256=I3X0z2ZZm9H68NLnOOs4vS6sn4THWLNjvgUcBDJB5i8,60902
|
|
48
|
-
icmd_python-3.
|
|
49
|
-
icmd_python-3.
|
|
50
|
-
icmd_python-3.
|
|
51
|
-
icmd_python-3.
|
|
52
|
-
icmd_python-3.
|
|
53
|
-
icmd_python-3.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|