icmd-python 3.3.0__py3-none-any.whl → 3.3.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
icmd/apps/agent.py CHANGED
@@ -212,5 +212,16 @@ class AgentApp:
212
212
  return self._client.request_json("get", "agent/providers")
213
213
 
214
214
  def usage(self) -> dict | list[dict]:
215
- """Get the group's monthly Assist token usage (``GET agent/assist/usage``)."""
215
+ """Get the group's Assist credit balance (``GET agent/assist/usage``).
216
+
217
+ Group admins only.
218
+
219
+ Returns
220
+ -------
221
+ dict
222
+ ``granted``, ``used``, ``balance`` and ``month_used`` in credits,
223
+ and ``status`` (``"ok"`` / ``"low"`` / ``"out"``). ``balance``
224
+ can be negative. An unmetered group returns ``None`` for all five
225
+ keys, ``status`` included.
226
+ """
216
227
  return self._client.request_json("get", "agent/assist/usage")
icmd/apps/jobs.py CHANGED
@@ -29,7 +29,7 @@ if TYPE_CHECKING:
29
29
  from .types import JobLogResponse
30
30
 
31
31
 
32
- _TERMINAL_STATUSES = frozenset({"successful", "failed", "partial", "cancelled"})
32
+ _TERMINAL_STATUSES = frozenset({"successful", "failed", "partial", "cancelled", "aborted"})
33
33
 
34
34
  #: Consecutive transient failures :meth:`JobResource.poll` absorbs before
35
35
  #: giving up. A training job outlives the 15-minute access token several
@@ -223,11 +223,12 @@ class JobResource(ReadOnlyResource):
223
223
  The design-level CALPHAD engine, written to
224
224
  ``input_dict["engine"]`` — qt-designer reads it from there and
225
225
  substitutes it into every model input spelled ``use_global``.
226
- One of ``"thermocalc"``, ``"pandat"``, ``"panengine"``,
227
- ``"pycalphad"``, ``"prisms"``; anything else is rejected by the
228
- backend with ``400 invalid_engine``. Optional when
226
+ One of ``"thermocalc"``, ``"pandat"``, ``"pycalphad"``; anything
227
+ else is rejected by the backend with ``400 invalid_engine``. Optional when
229
228
  *input_dict* already carries an ``engine``; supplying both is an
230
- error unless they agree, and supplying neither is an error too.
229
+ error unless they agree. Omit both when no model defers to the
230
+ job-level engine; if one does, the backend refuses the job with
231
+ ``400 missing_engine``.
231
232
  folder : str
232
233
  UUID of the job folder this job belongs to.
233
234
  name : str | None
@@ -265,7 +266,7 @@ class JobResource(ReadOnlyResource):
265
266
  ------
266
267
  ICMDValidationError
267
268
  If *engine* and a non-empty ``input_dict["engine"]`` disagree, if
268
- neither supplies an engine, if ``input_dict["engine"]`` holds
269
+ ``input_dict["engine"]`` holds
269
270
  something other than a string, or if a model named by name alone
270
271
  matches more than one catalog model.
271
272
  """
@@ -285,12 +286,10 @@ class JobResource(ReadOnlyResource):
285
286
  "Pass one or the other, or make them agree."
286
287
  )
287
288
  resolved = engine or document_engine
288
- if resolved is None:
289
- raise ICMDValidationError(
290
- "No engine given: pass engine= or set input_dict['engine'] "
291
- "(thermocalc, pandat, panengine, pycalphad, prisms)."
292
- )
293
- inputs["engine"] = resolved
289
+ if resolved is not None:
290
+ inputs["engine"] = resolved
291
+ else:
292
+ inputs.pop("engine", None)
294
293
  # Before the advisory, which needs a uuid per model to fetch a contract,
295
294
  # and regardless of ``check`` -- completing the reference is what makes
296
295
  # the document submittable, not a check on it.
@@ -432,9 +431,10 @@ class JobResource(ReadOnlyResource):
432
431
  Returns
433
432
  -------
434
433
  str | None
435
- The terminal status reached -- ``"failed"`` and ``"cancelled"``
436
- are terminal too, so a caller that needs the job to have *worked*
437
- must check this. ``None`` only when polling was interrupted.
434
+ The terminal status reached -- ``"failed"``, ``"partial"``,
435
+ ``"cancelled"`` and ``"aborted"`` are terminal too, so a caller that
436
+ needs the job to have *worked* must check this. ``None`` only when
437
+ polling was interrupted.
438
438
 
439
439
  Raises
440
440
  ------
@@ -646,7 +646,7 @@ class JobResource(ReadOnlyResource):
646
646
  # -----------------------------------------------------------------
647
647
 
648
648
  def resubmit(self, uuid: str) -> dict:
649
- """Re-run a completed/failed/cancelled job as-is (``POST {uuid}/resubmit``).
649
+ """Re-run a successful/failed/partial/cancelled/aborted job (``POST {uuid}/resubmit``).
650
650
 
651
651
  Takes no inputs, because the endpoint accepts none: it declares no request
652
652
  body and re-resolves the job's *stored* ``input_dict`` against the owning
icmd/cli/__init__.py CHANGED
@@ -5,6 +5,7 @@ Entry point: ``icmd`` (installed via ``[project.scripts]``).
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import contextlib
8
9
  from typing import Any
9
10
 
10
11
  import click
@@ -23,7 +24,7 @@ from icmd.version import __version__
23
24
  from .account import agent_usage, register_account_commands
24
25
  from .auth import CLIAuthenticator
25
26
  from .auth_status import auth_cmd
26
- from .client import _build_account_client
27
+ from .client import _build_account_client, build_client
27
28
  from .commands import (
28
29
  _domain_option,
29
30
  _format,
@@ -139,12 +140,21 @@ def login() -> None:
139
140
  @cli.command()
140
141
  @_domain_option
141
142
  def logout() -> None:
142
- """Clear cached credentials for an ICMD® instance."""
143
+ """Sign out of an ICMD® instance and clear its cached credentials."""
143
144
  ctx = click.get_current_context()
144
- normalized = normalize_domain(_require_domain(ctx))
145
- # Straight to the store, not through ``ICMD.logout``: that path treats a
146
- # developer-token client differently on purpose.
147
- CredentialManager().clear_domain_credentials(normalized)
145
+ domain = _require_domain(ctx)
146
+ normalized = normalize_domain(domain)
147
+ store = CredentialManager()
148
+ # Revoke a saved login on the server first, or its refresh token stays
149
+ # usable until it expires. Gated on a stored refresh token so that with
150
+ # nothing saved no client is built (no request, no prompt). Best effort:
151
+ # the local clear runs whatever the server does, Ctrl-C included.
152
+ try:
153
+ if store.get_refresh_token(normalized):
154
+ with contextlib.suppress(Exception):
155
+ build_client(domain).logout(clear_saved_credentials=True)
156
+ finally:
157
+ store.clear_domain_credentials(normalized)
148
158
  write_output({"status": "ok", "domain": normalized}, fmt=_format(ctx))
149
159
 
150
160
 
icmd/cli/account.py CHANGED
@@ -371,7 +371,10 @@ def invite_cancel(uuid: str, yes: bool) -> None:
371
371
  @click.command("usage")
372
372
  @_global_options
373
373
  def agent_usage() -> None:
374
- """Get the group's monthly ICMD® Assist token usage.
374
+ """Get the group's ICMD® Assist credit balance.
375
+
376
+ Prints ``granted`` / ``used`` / ``balance`` / ``month_used`` (credits) and
377
+ ``status``; ``null`` values mean the group is unmetered.
375
378
 
376
379
  Unlike the account commands, this is group-scoped: it honours ``--group``
377
380
  and injects ``owner_group`` (the backend gates on group-admin membership).
icmd/cli/commands.py CHANGED
@@ -1148,8 +1148,8 @@ def _make_submit_cmd(path: str) -> click.Command:
1148
1148
  default=None,
1149
1149
  help=(
1150
1150
  "Design-level CALPHAD engine written to input_dict['engine'] "
1151
- "(thermocalc, pandat, panengine, pycalphad, prisms). Omit when "
1152
- "the input_dict already carries one."
1151
+ "(thermocalc, pandat, pycalphad). Omit when the input_dict "
1152
+ "already carries one or no model defers to the job-level engine."
1153
1153
  ),
1154
1154
  )
1155
1155
  @click.option("--folder", required=True, help="UUID of the destination job folder.")
icmd/client.py CHANGED
@@ -148,11 +148,9 @@ class ICMD:
148
148
  def _save_credentials(self) -> None:
149
149
  """Persist the credentials produced by an interactive login.
150
150
 
151
- Only ever called after an interactive flow, and deliberately so: it
152
- writes ``self._session.refresh_token``, which a *refresh* has replaced
153
- with the server's rotated token. Persisting a rotation would roll the
154
- backend's absolute session cap forward on every refresh, so the refresh
155
- path saves the access token alone (:meth:`_cache_access_token`).
151
+ Only ever called after an interactive flow: it replaces the whole
152
+ record unconditionally, whereas a refresh must write only while the
153
+ record still holds the token it presented (:meth:`_cache_access_token`).
156
154
  """
157
155
  issued = self._session.refresh_token
158
156
  self._credentials.save_domain_credentials(
@@ -167,8 +165,9 @@ class ICMD:
167
165
  def _cache_access_token(self, refresh_token: str | None = None) -> None:
168
166
  """Cache the session's access token for the next process to reuse.
169
167
 
170
- Writes only the access-token fields — never a refresh token — which is
171
- what keeps a rotated token off disk.
168
+ A refresh token the server rotated in exchange for *refresh_token* is
169
+ written with it, in the same compare-and-swap, so the next process
170
+ presents the current token rather than a superseded one.
172
171
 
173
172
  Parameters
174
173
  ----------
@@ -185,11 +184,13 @@ class ICMD:
185
184
  """
186
185
  if not self._session.has_valid_token():
187
186
  return
187
+ current = self._session.refresh_token
188
188
  self._credentials.save_access_token(
189
189
  self._session.domain,
190
190
  self._session.access_token,
191
- expires_at=self._session.access_token_expires_at,
191
+ expires=self._session.access_token_expires_at,
192
192
  expected_refresh_token=refresh_token,
193
+ rotated_refresh_token=current if current and current != refresh_token else None,
193
194
  )
194
195
 
195
196
  def _adopt_cached_access_token(self) -> bool:
@@ -347,8 +348,11 @@ class ICMD:
347
348
  """Clear cached credentials for a domain.
348
349
 
349
350
  Removes the whole record — refresh token and cached access token
350
- alike — unconditionally: this is the explicit sign-out path, not a
351
- reaction to a token the server rejected.
351
+ alike — unconditionally, not as a reaction to a token the server
352
+ rejected. Local only: the server session stays live; use
353
+ :meth:`logout` to end it. Processes already running on that login
354
+ may then sign it out, since each rotates its refresh token on its
355
+ own once the record is gone.
352
356
 
353
357
  Can be called without an ``ICMD`` instance, which is useful when
354
358
  instantiation itself fails (e.g. after a server-side auth-method
@@ -506,10 +510,13 @@ class ICMD:
506
510
  Parameters
507
511
  ----------
508
512
  clear_saved_credentials : bool
509
- Also clear the stored refresh token, so the next client
510
- authenticates interactively. The cached access token is dropped
511
- either way — it belongs to the session just ended, and leaving it
512
- behind would let another process keep using it.
513
+ Also clear the stored refresh token. Every refresh token of a
514
+ login belongs to the session just ended, so once the server has
515
+ ended it the stored one is dead either way; clearing it only spares
516
+ the next client one rejected refresh before it authenticates
517
+ interactively. The cached access
518
+ token is dropped either way — it belongs to the session just ended,
519
+ and leaving it behind would let another process keep using it.
513
520
 
514
521
  Notes
515
522
  -----
icmd/compose.py CHANGED
@@ -93,11 +93,11 @@ def compose_input_dict(
93
93
 
94
94
  ``databases`` and ``engine`` are the design-level values, written at the top
95
95
  of the document rather than on any one model: a sequence of wire mappings or
96
- :class:`~icmd.types.Database` objects, and one of the catalog engine names
97
- (``thermocalc``, ``pandat``, ``panengine``, ``pycalphad``, ``prisms``). They
98
- are what a model input spelled ``use_global`` resolves against. Omitting
99
- either emits no key for it, which is what leaves the value unset -- an empty
100
- list or an empty string reads downstream as no value at all.
96
+ :class:`~icmd.types.Database` objects, and one of the CALPHAD engine names
97
+ ``thermocalc``, ``pandat``, ``pycalphad``. They are what a model input
98
+ spelled ``use_global`` resolves against. Omitting either emits no key for
99
+ it, which is what leaves the value unset -- an empty list or an empty string
100
+ reads downstream as no value at all.
101
101
 
102
102
  :class:`~icmd.types.FromVariable` and :class:`~icmd.types.FromModel` hold
103
103
  the object they reference, and resolve here to the variable's name and the
icmd/core/credentials.py CHANGED
@@ -41,7 +41,7 @@ Per-domain record::
41
41
 
42
42
  {
43
43
  "auth_method": "SAML", # required for auth-method detection
44
- "refresh_token": "…", # long-lived; never rotated on disk
44
+ "refresh_token": "…", # long-lived; replaced when a refresh rotates it
45
45
  "access_token": "…", # optional cache, short-lived
46
46
  "access_token_expires_at": "…", # absolute UTC ISO-8601 timestamp
47
47
  "saved_at": "…"
@@ -717,18 +717,15 @@ class CredentialManager:
717
717
  domain: str,
718
718
  access_token: str,
719
719
  *,
720
- expires_in: float | None = None,
721
- expires_at: datetime | None = None,
720
+ expires: float | datetime | None = None,
722
721
  expected_refresh_token: str | None = None,
722
+ rotated_refresh_token: str | None = None,
723
723
  ) -> bool:
724
- """Cache an access token for *domain* without touching the refresh token.
724
+ """Cache an access token for *domain*, with the refresh token rotated beside it.
725
725
 
726
726
  This is the only writer of the access-token fields, and it merges into
727
- the existing record rather than replacing it. That is deliberate: the
728
- stored refresh token must stay exactly as it is on disk, because the
729
- server mints a *new* refresh token on every exchange and persisting the
730
- rotation would roll the backend's absolute session cap forward
731
- indefinitely.
727
+ the existing record rather than replacing it, so the auth method and
728
+ the rest of the record survive.
732
729
 
733
730
  Parameters
734
731
  ----------
@@ -736,11 +733,10 @@ class CredentialManager:
736
733
  Normalised ICMD domain the token belongs to.
737
734
  access_token : str
738
735
  The access token to cache.
739
- expires_in : float | None
740
- Lifetime in seconds as reported by the server, measured from now.
741
- expires_at : datetime | None
742
- Absolute expiry; wins over *expires_in* when both are given. A
743
- naive value is interpreted as UTC.
736
+ expires : float | datetime | None
737
+ A number is the lifetime in seconds as reported by the server,
738
+ measured from now; a datetime is the absolute expiry, and a naive
739
+ one is interpreted as UTC.
744
740
  expected_refresh_token : str | None
745
741
  When given, cache only while the stored refresh token is still
746
742
  exactly this value — the compare-and-swap counterpart to
@@ -751,6 +747,12 @@ class CredentialManager:
751
747
  would resurrect a signed-out record (an access-token-only leftover
752
748
  that the next process happily adopts) or file one account's token
753
749
  under another's. ``None`` merges unconditionally.
750
+ rotated_refresh_token : str | None
751
+ The refresh token the server returned with *access_token*, when it
752
+ differs from the one presented. Written in the same rewrite, after
753
+ the *expected_refresh_token* check, so both tokens land or neither
754
+ does. A failed write is logged at WARNING: the stored token is then
755
+ one the server has already superseded.
754
756
 
755
757
  Returns
756
758
  -------
@@ -765,24 +767,26 @@ class CredentialManager:
765
767
  Raises
766
768
  ------
767
769
  ValueError
768
- If neither *expires_in* nor *expires_at* is given.
770
+ If *expires* is not given.
769
771
  """
770
- if expires_in is None and expires_at is None:
771
- raise ValueError("save_access_token requires either expires_in or expires_at")
772
+ if expires is None:
773
+ raise ValueError("save_access_token requires expires")
772
774
 
773
775
  if not access_token or not isinstance(access_token, str):
774
776
  logger.debug("Refusing to cache an empty access token for %s", domain)
775
777
  return False
776
778
 
777
- resolved = self._resolve_expiry(expires_in, expires_at)
779
+ resolved = self._resolve_expiry(expires)
778
780
  if resolved is None:
779
781
  logger.debug("Access token for %s has no usable expiry; not caching", domain)
780
782
  return False
781
783
 
782
784
  stamp = resolved.isoformat()
783
785
  canonical = canonical_domain(domain)
786
+ attempted = False
784
787
 
785
788
  def mutate(records: dict[str, Any]) -> bool:
789
+ nonlocal attempted
786
790
  # Folded before the compare-and-swap, so the token compared against
787
791
  # is the one this domain's single surviving record holds. Refusing
788
792
  # below writes nothing and discards the fold with it; the next
@@ -804,23 +808,32 @@ class CredentialManager:
804
808
  records[canonical] = record
805
809
  record[_ACCESS_TOKEN_KEY] = access_token
806
810
  record[_ACCESS_TOKEN_EXPIRES_AT_KEY] = stamp
811
+ if rotated_refresh_token:
812
+ record[_REFRESH_TOKEN_KEY] = rotated_refresh_token
813
+ attempted = True
807
814
  return True
808
815
 
809
- return self._mutate_store(mutate)
816
+ saved = self._mutate_store(mutate)
817
+ if attempted and not saved and rotated_refresh_token:
818
+ logger.warning(
819
+ "Could not save the rotated refresh token for %s; this process's next "
820
+ "refresh and any later process will present a superseded one, which "
821
+ "signs the login out.",
822
+ domain,
823
+ )
824
+ return saved
810
825
 
811
826
  @staticmethod
812
- def _resolve_expiry(expires_in: float | None, expires_at: datetime | None) -> datetime | None:
813
- """Resolve the two expiry forms into one absolute UTC timestamp."""
814
- if expires_at is not None:
815
- if not isinstance(expires_at, datetime):
816
- return None
817
- return expires_at.replace(tzinfo=UTC) if expires_at.tzinfo is None else expires_at
827
+ def _resolve_expiry(expires: float | datetime) -> datetime | None:
828
+ """Resolve either expiry form into one absolute UTC timestamp."""
829
+ if isinstance(expires, datetime):
830
+ return expires.replace(tzinfo=UTC) if expires.tzinfo is None else expires
818
831
 
819
- if not isinstance(expires_in, int | float) or isinstance(expires_in, bool):
832
+ if not isinstance(expires, int | float) or isinstance(expires, bool):
820
833
  return None
821
- if expires_in <= 0:
834
+ if expires <= 0:
822
835
  return None
823
- return datetime.now(UTC) + timedelta(seconds=float(expires_in))
836
+ return datetime.now(UTC) + timedelta(seconds=float(expires))
824
837
 
825
838
  def _mutate_store(self, mutate: Callable[[dict[str, Any]], bool]) -> bool:
826
839
  """Read, mutate, and atomically rewrite the store under the credential lock.
icmd/core/session.py CHANGED
@@ -9,6 +9,7 @@ this module only manages token state.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import logging
12
13
  import os
13
14
  import re
14
15
  import warnings
@@ -60,6 +61,8 @@ if TYPE_CHECKING:
60
61
  from .constants import HTTPMethod
61
62
  from .contracts import ContractClient
62
63
 
64
+ logger = logging.getLogger(__name__)
65
+
63
66
 
64
67
  class ResourceClient(Protocol):
65
68
  """Structural type for objects that can make JSON API requests."""
@@ -622,6 +625,16 @@ class BearerAuth(AuthBase):
622
625
  return r
623
626
 
624
627
 
628
+ def _no_auth(r: requests.PreparedRequest) -> requests.PreparedRequest:
629
+ """Send one request without the session's bearer.
630
+
631
+ Passed as a per-request ``auth=``, which displaces ``Session.auth``;
632
+ ``auth=None`` would not, since ``requests`` falls back to the session's.
633
+ """
634
+ r.headers.pop("Authorization", None)
635
+ return r
636
+
637
+
625
638
  # ---------------------------------------------------------------------------
626
639
  # Session
627
640
  # ---------------------------------------------------------------------------
@@ -708,7 +721,6 @@ class Session:
708
721
  EXCLUDED_ENDPOINTS: ClassVar[set[str]] = {
709
722
  LOGIN,
710
723
  AUTH_REFRESH,
711
- AUTH_LOGOUT,
712
724
  MFA_VERIFY,
713
725
  MFA_EMAIL_GENERATE,
714
726
  SAML_LOGIN,
@@ -716,6 +728,11 @@ class Session:
716
728
  PUBLIC_CONFIG,
717
729
  }
718
730
 
731
+ #: ``AllowAny`` endpoints that never carry the bearer: DRF rejects a stale one
732
+ #: before the view runs. The refresh POST opts out itself in
733
+ #: :meth:`refresh_access_token`; everything else, logout included, keeps it.
734
+ _BEARERLESS_ENDPOINTS: ClassVar[frozenset[str]] = frozenset({LOGIN, PUBLIC_CONFIG})
735
+
719
736
  def __init__(self, domain: str) -> None:
720
737
  """Initialise the session with a validated domain.
721
738
 
@@ -910,6 +927,7 @@ class Session:
910
927
  f"{self.api_root}/{AUTH_REFRESH}/",
911
928
  json={"refresh_token": self._refresh_token},
912
929
  timeout=self._REFRESH_TIMEOUT,
930
+ auth=_no_auth,
913
931
  )
914
932
  except requests.RequestException as exc:
915
933
  # Nothing came back, so nothing implicates the stored token.
@@ -1026,6 +1044,9 @@ class Session:
1026
1044
 
1027
1045
  if "timeout" not in kwargs:
1028
1046
  kwargs["timeout"] = self._timeout
1047
+ if endpoint in self._BEARERLESS_ENDPOINTS:
1048
+ # A default, so a caller's own ``auth=`` still wins.
1049
+ kwargs.setdefault("auth", _no_auth)
1029
1050
 
1030
1051
  renewal_failure = self._renew_expired_token(endpoint)
1031
1052
 
@@ -1143,15 +1164,11 @@ class Session:
1143
1164
 
1144
1165
  # The server already refused this exact access token on a 401 that a
1145
1166
  # refresh did not fix, so exchanging again cannot help — and would not
1146
- # be free: the on-disk refresh token is deliberately never rotated, so
1147
- # every attempt presents the same one and lands in the same
1148
- # ``(client IP, sha256(refresh token))`` throttle bucket. Without this,
1149
- # a 401 that is not about expiry (an authorization failure, a proxy
1150
- # stripping ``Authorization``) costs one refresh POST per *request* and
1151
- # exhausts the bucket after 30 — the same refresh lockout the
1152
- # empty-token guard above prevents, reached by another door. No reset
1153
- # is needed: the guard compares against the token currently held, so
1154
- # any path that installs a new one clears it.
1167
+ # be free. Without this, a 401 that is not about expiry (an
1168
+ # authorization failure, a proxy stripping ``Authorization``) costs one
1169
+ # refresh POST per *request*, each counted by the server's refresh
1170
+ # throttle. No reset is needed: the guard compares against the token
1171
+ # currently held, so any path that installs a new one clears it.
1155
1172
  if self._access_token and self._access_token == self._refresh_rejected_token:
1156
1173
  return response
1157
1174
 
@@ -1306,18 +1323,34 @@ class Session:
1306
1323
  # -----------------------------------------------------------------
1307
1324
 
1308
1325
  def logout(self) -> None:
1309
- """Logout from the server and clear in-memory tokens.
1326
+ """Revoke the server session and clear in-memory tokens.
1310
1327
 
1311
- Returns silently — callers decide how to communicate success.
1328
+ The server revokes the session named by the bearer, so the POST takes
1329
+ the same renew-then-send path as any authenticated request, including
1330
+ the single refresh-and-retry on a ``401``. Best effort: it never
1331
+ raises, and the outcome is logged at DEBUG — callers decide how to
1332
+ communicate success.
1312
1333
  """
1313
1334
  try:
1314
1335
  if self._access_token:
1315
- self._raw_request(
1316
- "POST",
1317
- f"{self.api_root}/{AUTH_LOGOUT}/",
1318
- timeout=5,
1319
- )
1320
- except Exception:
1321
- pass
1336
+ self._renew_expired_token(AUTH_LOGOUT)
1337
+ if (
1338
+ self._refresh_coordinator is not None
1339
+ and self._proactive_renewal
1340
+ and self.is_token_expired()
1341
+ ):
1342
+ # Renewal had its chance and failed; the server would only
1343
+ # answer this token with a 401.
1344
+ logger.debug("Logout skipped: the access token expired and was not renewed")
1345
+ else:
1346
+ url = f"{self.api_root}/{AUTH_LOGOUT}/"
1347
+ response = self._raw_request("POST", url, timeout=5)
1348
+ if response.status_code == HTTPStatus.UNAUTHORIZED:
1349
+ response = self._retry_on_unauthorized(
1350
+ response, AUTH_LOGOUT, "POST", url, timeout=5
1351
+ )
1352
+ logger.debug("Logout answered HTTP %s", response.status_code)
1353
+ except Exception as exc:
1354
+ logger.debug("Logout request failed: %s", exc)
1322
1355
 
1323
1356
  self.clear_tokens()
icmd/types.py CHANGED
@@ -22,6 +22,7 @@ import csv
22
22
  import math
23
23
  from collections.abc import Mapping, Sequence
24
24
  from copy import deepcopy
25
+ from decimal import Decimal
25
26
  from io import StringIO
26
27
  from typing import TYPE_CHECKING, Any, Protocol, Self, runtime_checkable
27
28
 
@@ -458,6 +459,9 @@ class DatasetCompositionalVariable(_Envelope):
458
459
  per element -- nothing downstream reads a dataset reference on the
459
460
  composition itself. :meth:`DatasetSource.composition` mints the composition
460
461
  and its variables together, which is the only way they stay consistent.
462
+ Constructing one here normalizes nothing and qt-designer samples on the
463
+ composition's own unit rather than the one labelled here, so fraction values
464
+ are unsupported: express them in percent through that method instead.
461
465
 
462
466
  The component is keyed ``name`` on the wire: that is the key the sampler
463
467
  matches against the composition's components.
@@ -550,6 +554,49 @@ _BALANCE = "bal"
550
554
  #: 9958180, lines 106-109).
551
555
  _PERCENT_UNITS = frozenset({"mass_percent", "mole_percent"})
552
556
 
557
+ #: The unit a fraction-labelled dataset's cells are in once expressed in
558
+ #: percent. A dataset may be labelled in fractions and a job may not, so these
559
+ #: are read off the dataset rather than added to :data:`_PERCENT_UNITS`.
560
+ _FRACTION_TO_PERCENT = {"mass_fraction": "mass_percent", "mole_fraction": "mole_percent"}
561
+
562
+
563
+ class _CompositionUnitUnset:
564
+ """The type of :data:`_COMPOSITION_UNIT_UNSET`, so it renders as prose.
565
+
566
+ ``inspect.signature`` renders a default by ``repr``, so a bare ``object()``
567
+ puts a memory address in :meth:`DatasetSource.composition`'s signature --
568
+ in ``help()`` and in every IDE tooltip on a public method.
569
+ """
570
+
571
+ def __repr__(self) -> str:
572
+ """Return what the signature shows where no unit was passed."""
573
+ return "<from the dataset>"
574
+
575
+
576
+ #: :meth:`DatasetSource.composition`'s "not given" default, and nothing else's.
577
+ #: Not ``None`` (:data:`_UNSET`): a plain parameter keeps no record of what the
578
+ #: caller passed, the way a Pydantic field's ``model_fields_set`` does, so
579
+ #: ``composition_unit=None`` is a value passed -- refused rather than resolved
580
+ #: from the dataset.
581
+ _COMPOSITION_UNIT_UNSET: Any = _CompositionUnitUnset()
582
+
583
+
584
+ def _as_percent(values: list[_Number | str]) -> list[_Number | str]:
585
+ """Return one composition column's fraction cells in percent, exactly.
586
+
587
+ ``Decimal(str(value)) * 100`` rather than ``value * 100``: the web app
588
+ multiplies in BigNumber, so ``0.07`` becomes ``7`` there and must not become
589
+ ``7.000000000000001`` here. Neither side scales the decimal the CSV cell was
590
+ written with -- both round it through a binary float first (``Number(value)``
591
+ there, :func:`_composition_values` here, and :func:`_as_numbers` before it
592
+ where the whole column parses) -- so what this buys is that the two agree,
593
+ cell for cell. ``bal`` markers are the strings here, and pass through
594
+ untouched.
595
+ """
596
+ return [
597
+ value if isinstance(value, str) else float(Decimal(str(value)) * 100) for value in values
598
+ ]
599
+
553
600
 
554
601
  def _composition_values(column: str, values: list[_Number | str]) -> list[_Number | str]:
555
602
  """Return one composition column as numbers, keeping its ``bal`` markers.
@@ -564,15 +611,75 @@ def _composition_values(column: str, values: list[_Number | str]) -> list[_Numbe
564
611
  coerced.append(_BALANCE)
565
612
  continue
566
613
  try:
567
- coerced.append(float(value))
614
+ number = float(value)
568
615
  except ValueError:
569
616
  raise ValueError(
570
617
  f"Composition column {column!r} holds {value!r}, which is neither a "
571
618
  f"number nor the {_BALANCE!r} balance marker."
572
619
  ) from None
620
+ # ``float`` reads ``nan`` and ``inf`` and ``Decimal`` scales them, so
621
+ # unguarded the balance element's remainder of 100 comes out ``nan`` and
622
+ # the cell reaches the wire as JSON no strict parser accepts.
623
+ if not math.isfinite(number):
624
+ raise ValueError(
625
+ f"Composition column {column!r} holds {value!r}, which is not a finite "
626
+ "number. Drop the row, or fill the cell in."
627
+ )
628
+ coerced.append(number)
573
629
  return coerced
574
630
 
575
631
 
632
+ def _resolve_composition_unit(
633
+ dataset_name: str, declared_unit: str | None, passed_unit: str
634
+ ) -> tuple[str, str | None]:
635
+ """Return the percent unit a composition carries, and the one its cells are in.
636
+
637
+ The second is the percent unit a fraction-labelled dataset's cells reach
638
+ once scaled, and ``None`` for every other label, including none -- which is
639
+ what tells the caller whether to scale them at all.
640
+
641
+ ``passed_unit`` left at :data:`_COMPOSITION_UNIT_UNSET` resolves from the
642
+ dataset: a fraction label through its matching percent unit, a percent label
643
+ as it stands, ``mass_percent`` only where the dataset declares nothing at
644
+ all. A label this SDK cannot read is refused with the dataset blamed -- an
645
+ empty one included, since a dataset carrying an empty label has declared a
646
+ value -- where a unit passed is refused with the caller blamed.
647
+ """
648
+ scaled_unit = _FRACTION_TO_PERCENT.get(declared_unit or "")
649
+ from_dataset = passed_unit is _COMPOSITION_UNIT_UNSET
650
+ composition_unit = passed_unit
651
+ if from_dataset:
652
+ # A fraction label resolves through its scaled percent unit; every
653
+ # other label stands and is checked below, as a passed one is.
654
+ declared = scaled_unit or declared_unit
655
+ composition_unit = "mass_percent" if declared is None else declared
656
+ if composition_unit not in _PERCENT_UNITS:
657
+ if from_dataset:
658
+ raise ValueError(
659
+ f"Dataset {dataset_name!r} declares composition_unit "
660
+ f"{declared_unit!r}, which is neither one of "
661
+ f"{sorted(_PERCENT_UNITS)} nor a fraction of one, so its cells cannot "
662
+ "be read. Pass the unit they are in: "
663
+ "composition(name, composition_unit=...)."
664
+ )
665
+ raise ValueError(
666
+ f"composition_unit must be one of {sorted(_PERCENT_UNITS)}; got "
667
+ f"{composition_unit!r}. The balance element is filled in as what is left "
668
+ "of 100, which is a wrong number in any other unit."
669
+ )
670
+ # Both units are now one of the two in ``_PERCENT_UNITS``, so they differ
671
+ # only in basis. Scaling the cells cannot change what they measure, so a
672
+ # caller naming the other basis has the dataset wrong.
673
+ if scaled_unit is not None and composition_unit != scaled_unit:
674
+ raise ValueError(
675
+ f"Dataset {dataset_name!r} is labelled {declared_unit!r}, so its "
676
+ f"cells are {scaled_unit!r} once expressed in percent; "
677
+ f"composition_unit={composition_unit!r} is the other basis. Pass "
678
+ f"{scaled_unit!r}, or leave composition_unit unset."
679
+ )
680
+ return composition_unit, scaled_unit
681
+
682
+
576
683
  class DatasetSource:
577
684
  """One uploaded dataset, read once, minting variables from its columns.
578
685
 
@@ -598,6 +705,13 @@ class DatasetSource:
598
705
  Its name, carried onto every variable minted here.
599
706
  columns : Mapping[str, list[int | float | str]]
600
707
  The parsed columns, by column name.
708
+ composition_unit : str | None
709
+ The unit the dataset itself declares its composition columns are in, as
710
+ :meth:`load` reads it off the dataset's metadata. :meth:`composition`
711
+ scales fraction cells on the strength of it, and resolves the
712
+ composition's own unit from it when none is passed. Omit it on a
713
+ hand-built source, which then behaves as a dataset carrying no unit
714
+ does.
601
715
  """
602
716
 
603
717
  def __init__(
@@ -605,16 +719,29 @@ class DatasetSource:
605
719
  dataset_uuid: str,
606
720
  dataset_name: str,
607
721
  columns: Mapping[str, list[_Number | str]],
722
+ *,
723
+ composition_unit: str | None = None,
608
724
  ) -> None:
609
725
  self.uuid = dataset_uuid
610
726
  self.name = dataset_name
611
727
  self._columns = dict(columns)
728
+ self._composition_unit = composition_unit
612
729
 
613
730
  @classmethod
614
731
  def load(cls, client: "ICMD", dataset_uuid: str) -> Self:
615
732
  """Fetch one dataset and return the source minting its variables."""
616
733
  body = client.workspace.dataset.get(dataset_uuid, inject_group=True)
617
- return cls(dataset_uuid, body["name"], _parse_columns(body["csv_data"]))
734
+ # This server always sends ``metadata`` as a dict; the inner ``dataset``
735
+ # block is the one that is really null, on a dataset saved without a
736
+ # unit. The outer guard is insurance for a tenant on an older backend.
737
+ metadata = body.get("metadata") or {}
738
+ declared_unit = (metadata.get("dataset") or {}).get("composition_unit")
739
+ return cls(
740
+ dataset_uuid,
741
+ body["name"],
742
+ _parse_columns(body["csv_data"]),
743
+ composition_unit=declared_unit,
744
+ )
618
745
 
619
746
  def _column(self, column: str) -> list[_Number | str]:
620
747
  """Return one column's values, naming the ones there are when it is absent."""
@@ -641,7 +768,7 @@ class DatasetSource:
641
768
  self,
642
769
  name: str,
643
770
  *,
644
- composition_unit: str = "mass_percent",
771
+ composition_unit: str = _COMPOSITION_UNIT_UNSET,
645
772
  columns: Sequence[str] | None = None,
646
773
  ) -> tuple["Composition", list["DatasetCompositionalVariable"]]:
647
774
  """Resolve this dataset's compositions into the pair the wire accepts.
@@ -653,16 +780,29 @@ class DatasetSource:
653
780
  app does too: a composition that only references a dataset is read by
654
781
  nothing, and the job runs with no composition columns at all.
655
782
 
783
+ The dataset's declared label decides the **cells**: a ``mass_fraction``
784
+ or ``mole_fraction`` label has every numeric cell scaled by 100, since
785
+ no fraction unit reaches the wire at all; a percent label leaves them as
786
+ they stand. ``composition_unit`` decides the **label** the composition
787
+ carries and nothing else -- passing one does not turn that scaling off.
788
+ Left unset it follows the dataset as well: a fraction label's matching
789
+ percent unit, a percent label as it stands, ``mass_percent`` for a
790
+ dataset declaring nothing, and an error for a label this SDK does not
791
+ know, which passing a unit overrides.
792
+
793
+ That unit is cross-checked only where this SDK converts: against a
794
+ fraction-labelled dataset the other basis is refused, since scaling
795
+ cannot change what the cells measure. A percent-labelled dataset takes
796
+ whatever is passed -- the label is free-form metadata the caller may be
797
+ correcting.
798
+
656
799
  ``columns`` defaults to every column named after an element. Each row
657
800
  must mark exactly one balance element with ``bal``, which is what the
658
801
  uploaded composition dataset carries.
659
802
  """
660
- if composition_unit not in _PERCENT_UNITS:
661
- raise ValueError(
662
- f"composition_unit must be one of {sorted(_PERCENT_UNITS)}; got "
663
- f"{composition_unit!r}. The balance element is filled in as what is left "
664
- "of 100, which is a wrong number in any other unit."
665
- )
803
+ composition_unit, scaled_unit = _resolve_composition_unit(
804
+ self.name, self._composition_unit, composition_unit
805
+ )
666
806
  selected = (
667
807
  list(columns)
668
808
  if columns is not None
@@ -677,6 +817,10 @@ class DatasetSource:
677
817
  composition_columns = {
678
818
  column: _composition_values(column, self._column(column)) for column in selected
679
819
  }
820
+ if scaled_unit is not None:
821
+ composition_columns = {
822
+ column: _as_percent(values) for column, values in composition_columns.items()
823
+ }
680
824
  if not all(composition_columns.values()):
681
825
  raise ValueError(
682
826
  f"Dataset {self.name!r} has no rows, so there is no composition to "
icmd/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "3.3.0"
1
+ __version__ = "3.3.5"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icmd-python
3
- Version: 3.3.0
3
+ Version: 3.3.5
4
4
  Summary: Python client app for ICMD®
5
5
  Author: QuesTek Innovations LLC
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,31 +1,31 @@
1
1
  icmd/__init__.py,sha256=BoE6tl0PaBgXqXmL_QjebFos5eOvS91Dj6tjJwolezk,2739
2
- icmd/client.py,sha256=nqp6MZh2BdT2Qx1cv_Dg306BzvTcHJ1F2Dd0NU1Oh20,22150
3
- icmd/compose.py,sha256=WaZEF2JcrMbJK90nhGrL-0iyJRyuQgq6Nh1-OgK71K8,6960
2
+ icmd/client.py,sha256=8PXqAskxzkuUwz2wtHYFp9kDAgQlyOxDNbEyVSKayeY,22643
3
+ icmd/compose.py,sha256=V_B4EYuX01oJexnGGvhIwDUKgfx_Fyd0lPVvSRUoH8c,6931
4
4
  icmd/links.py,sha256=I8v8_xhI6lbUwrMjIUcYYcqKUi2PIPm6zQ7mXYP9A5E,6827
5
5
  icmd/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- icmd/types.py,sha256=pMAaQaMHolaiAYh_aBG0NZWf5yiqxY5XbRmekxCUREU,72026
7
- icmd/version.py,sha256=MPSbkJS_-QSkLK7sMlFXFf5iWlbcSL18Dj2ZiMCtS9c,22
6
+ icmd/types.py,sha256=LizuU96hF7VRjl4OlyF0oPcao1cKe795YXEsLW9fKI4,79496
7
+ icmd/version.py,sha256=QO4UWv5ZUgBpuBbLNKWKecIDPPTa5HfOdEYEe2q2Afo,22
8
8
  icmd/_vendor/__init__.py,sha256=VFA4EQd2l76sxgkHPUpgzZavFF6kFdpq9v71AZsPIdQ,579
9
9
  icmd/_vendor/assembler.py,sha256=U8VPlqN1605RJQ5qMVoNrL3uJ7NpUe8BVmD8q06IN5w,5282
10
10
  icmd/apps/__init__.py,sha256=ak2ch1UU-qaiyOT1eCdRwqSI0Avhzyw5DLkw-U2ivQQ,659
11
11
  icmd/apps/account.py,sha256=BWDO0Zysv0ygLVhbeIk82BjsvuDVb_vBdu6jqBPw3tc,14557
12
- icmd/apps/agent.py,sha256=oSgIjchkcWi83-1Bsqywe2yqzfz0cM0XTyidQPoo7IM,8037
12
+ icmd/apps/agent.py,sha256=MmWuH1sYqo-hOhNNuTwh930H_BFXs7oMP0QgyzaC0Iw,8387
13
13
  icmd/apps/base.py,sha256=y4uvIiomCLia89UL2rV4oufLBUQaODZ8qwl-EwDtjhM,18979
14
14
  icmd/apps/constants.py,sha256=5xoIwFNGOaAp2cHAlVDlbzJ7ObQHYXJgVGMBfNLCHzI,757
15
15
  icmd/apps/documents.py,sha256=8_wFc7NLUFQMYZA5hqsciXniHECZEWS-egX_ni8F4LE,3720
16
- icmd/apps/jobs.py,sha256=TVSSR6HLmQuSc0mA0Zrax_LclncOmmAMfn7ITf-aT9E,35319
16
+ icmd/apps/jobs.py,sha256=mUwV1wD4NlxOuYbK6a2HFZvZOtd1cXt-NQMZnmsYo1c,35282
17
17
  icmd/apps/navigation.py,sha256=mbEJG33Z-Nha5txvMpL63sT-UqsgFgZ-NMhTU7L7gI0,3738
18
18
  icmd/apps/results.py,sha256=lPs9iBoW49h0HTIYaUIau7QZVok7RdpSeawnxyA0ETU,7246
19
19
  icmd/apps/studio.py,sha256=XN6gVCqkTzp7qH9I-UOhW7UJ2RfkGv60H-G7ulkR850,2419
20
20
  icmd/apps/suite.py,sha256=04MIHUima_RGfnHyBPjyxcghcIMHhgDLWPcex6tIWls,2761
21
21
  icmd/apps/types.py,sha256=jOzykkXWj9e_lk805_ABCC-sWBwIpJGN3HEb2AyQ9vw,1290
22
22
  icmd/apps/workspace.py,sha256=BXCOLQo5BMu-okW2Ink8L_FPhU8r7OWy2jDsvilU3Oo,3055
23
- icmd/cli/__init__.py,sha256=fTmRemUAxPv5eksJEMtajM01WL0mnXEmrGBidkpyEbA,6944
24
- icmd/cli/account.py,sha256=8TM-zwOUIJVc11IePT0iL7RC7ZoTBBI5oBMIMW6Ms2Q,13723
23
+ icmd/cli/__init__.py,sha256=mBpAlW1BCKglPzdDffTc4uLdr_2Ri7qnVC2I-Q9FjEA,7395
24
+ icmd/cli/account.py,sha256=equTAGWR3NCCMYEPTw4IZKHEtBEdGD8F4Pp8cfiSNoU,13859
25
25
  icmd/cli/auth.py,sha256=UFpEf-lLlkbphivgBfd9NxAEqHcCsPmEl-DXet-NStg,3317
26
26
  icmd/cli/auth_status.py,sha256=6wkI3Q0c-t8DBOfuPaN_xXEUcL9HLZm1jX4lQ76X3Ag,13505
27
27
  icmd/cli/client.py,sha256=3iToPSABFYWWUPn-uDVFAv9GoBWQXP04l2Id_aYgd64,2186
28
- icmd/cli/commands.py,sha256=AvAvK9VNk7WydcpeB019ZetGDKuJqWN3bwhY3hcpSpc,54828
28
+ icmd/cli/commands.py,sha256=JdEsIlsMvThWaMHbNmOWyESKWRbnpdTt_qxY56RHpxo,54852
29
29
  icmd/cli/diagnostics.py,sha256=1fXgKmpUMA5U-_79AzZ0uSibFmDTK_CLCe91YhBnM8s,2747
30
30
  icmd/cli/domain.py,sha256=MiGzdKwKaOEvMshsPPJTmdBm7pCTjGM98MOg9RhAL_k,874
31
31
  icmd/cli/links.py,sha256=7CMhKUJ-qKQmk2I-KCdn6oF06cdCq0v5jQDGU7gEJ30,14039
@@ -36,15 +36,15 @@ icmd/core/advisory.py,sha256=ErXByq5UgpSGkjIiAjnmVCBLdY76REiU-Ilf_HoJ06Y,20783
36
36
  icmd/core/auth.py,sha256=bG6eLbSkmyDWLyprx7NJMDwLTGsOIrsRdBZ3f6vz1vk,29480
37
37
  icmd/core/constants.py,sha256=P3a01sDt5WmGYqkp5hu4UqWbMgfp50o6rBV3q9jU4yA,2339
38
38
  icmd/core/contracts.py,sha256=0Dw7tq_IxQY5YC4CKtaubJ0Npa4Ae66-sm-0UuqH_no,9901
39
- icmd/core/credentials.py,sha256=Ow3S3RMfvMJL1iJsm_37ZX8T3spk-Rttt80TmsRhUUI,46297
39
+ icmd/core/credentials.py,sha256=39vyoFH1_MrzD9TpTh4FVPhWbrY5X35zGCS22KeTi_8,46835
40
40
  icmd/core/display.py,sha256=RkgJhr2Bfw3-ZjGvE100ddD6oyAAUUAJfBAy6DvX5ik,13060
41
41
  icmd/core/exceptions.py,sha256=9iFCw93yZ0Eh1x_6-acPU8pVtaUy7EhSz-h1JkYS4M4,6985
42
42
  icmd/core/groups.py,sha256=OFbQce42DLDK0rDAdMbh8H7imNPAkBJMl7o7tbKQ4Iw,7211
43
43
  icmd/core/model_classes.py,sha256=lOwTwZzmQ247fddFMjPVo43Z_VZfdPhhM0NI8LfRqlc,40047
44
- icmd/core/session.py,sha256=JKlvzairnw9ZQBTzN1EyABM2OwgDX98-ZzwpHWa_2Ls,59188
45
- icmd_python-3.3.0.dist-info/licenses/LICENSE,sha256=644lGvCmDFa5hQl6xdhaYk3o-1oWqPXAIiiQlvqeZdE,1028
46
- icmd_python-3.3.0.dist-info/METADATA,sha256=NDMgci_LiXhHg7gN0vjCzu4wH6ygTIQ6_m9fN7M7dhM,4919
47
- icmd_python-3.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
48
- icmd_python-3.3.0.dist-info/entry_points.txt,sha256=CFX537PlyyPQL3Q_3TKUFeiiDSkOAboqaLEy9AIEwVU,39
49
- icmd_python-3.3.0.dist-info/top_level.txt,sha256=olzM6Z_QEwg7RrFVOnkgO0I2Ps2m7vhSqYQZYeiawcw,5
50
- icmd_python-3.3.0.dist-info/RECORD,,
44
+ icmd/core/session.py,sha256=_hdA3Yo_dH1A_qRH1xpyEB8sBkUNQKcUywc54WsPZos,60894
45
+ icmd_python-3.3.5.dist-info/licenses/LICENSE,sha256=644lGvCmDFa5hQl6xdhaYk3o-1oWqPXAIiiQlvqeZdE,1028
46
+ icmd_python-3.3.5.dist-info/METADATA,sha256=YXJOLdFBGc9bfWGYDvIWMAKdFNyPH_WSvSWU32oaBxY,4919
47
+ icmd_python-3.3.5.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
48
+ icmd_python-3.3.5.dist-info/entry_points.txt,sha256=CFX537PlyyPQL3Q_3TKUFeiiDSkOAboqaLEy9AIEwVU,39
49
+ icmd_python-3.3.5.dist-info/top_level.txt,sha256=olzM6Z_QEwg7RrFVOnkgO0I2Ps2m7vhSqYQZYeiawcw,5
50
+ icmd_python-3.3.5.dist-info/RECORD,,