icmd-python 3.3.4__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/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/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/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."""
@@ -718,7 +721,6 @@ class Session:
718
721
  EXCLUDED_ENDPOINTS: ClassVar[set[str]] = {
719
722
  LOGIN,
720
723
  AUTH_REFRESH,
721
- AUTH_LOGOUT,
722
724
  MFA_VERIFY,
723
725
  MFA_EMAIL_GENERATE,
724
726
  SAML_LOGIN,
@@ -1162,15 +1164,11 @@ class Session:
1162
1164
 
1163
1165
  # The server already refused this exact access token on a 401 that a
1164
1166
  # refresh did not fix, so exchanging again cannot help — and would not
1165
- # be free: the on-disk refresh token is deliberately never rotated, so
1166
- # every attempt presents the same one and lands in the same
1167
- # ``(client IP, sha256(refresh token))`` throttle bucket. Without this,
1168
- # a 401 that is not about expiry (an authorization failure, a proxy
1169
- # stripping ``Authorization``) costs one refresh POST per *request* and
1170
- # exhausts the bucket after 30 — the same refresh lockout the
1171
- # empty-token guard above prevents, reached by another door. No reset
1172
- # is needed: the guard compares against the token currently held, so
1173
- # 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.
1174
1172
  if self._access_token and self._access_token == self._refresh_rejected_token:
1175
1173
  return response
1176
1174
 
@@ -1325,18 +1323,34 @@ class Session:
1325
1323
  # -----------------------------------------------------------------
1326
1324
 
1327
1325
  def logout(self) -> None:
1328
- """Logout from the server and clear in-memory tokens.
1326
+ """Revoke the server session and clear in-memory tokens.
1329
1327
 
1330
- 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.
1331
1333
  """
1332
1334
  try:
1333
1335
  if self._access_token:
1334
- self._raw_request(
1335
- "POST",
1336
- f"{self.api_root}/{AUTH_LOGOUT}/",
1337
- timeout=5,
1338
- )
1339
- except Exception:
1340
- 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)
1341
1355
 
1342
1356
  self.clear_tokens()
icmd/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "3.3.4"
1
+ __version__ = "3.3.5"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icmd-python
3
- Version: 3.3.4
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,15 +1,15 @@
1
1
  icmd/__init__.py,sha256=BoE6tl0PaBgXqXmL_QjebFos5eOvS91Dj6tjJwolezk,2739
2
- icmd/client.py,sha256=nqp6MZh2BdT2Qx1cv_Dg306BzvTcHJ1F2Dd0NU1Oh20,22150
2
+ icmd/client.py,sha256=8PXqAskxzkuUwz2wtHYFp9kDAgQlyOxDNbEyVSKayeY,22643
3
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
6
  icmd/types.py,sha256=LizuU96hF7VRjl4OlyF0oPcao1cKe795YXEsLW9fKI4,79496
7
- icmd/version.py,sha256=Smy9HlTNV-SDgnJHDo2272CgsZJYueox7aaoRhK744k,22
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
@@ -20,8 +20,8 @@ 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
@@ -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=WWsZDUampgxnPQj-aM5l4OkLS2ZgvSMstjqNCsYAuI0,60054
45
- icmd_python-3.3.4.dist-info/licenses/LICENSE,sha256=644lGvCmDFa5hQl6xdhaYk3o-1oWqPXAIiiQlvqeZdE,1028
46
- icmd_python-3.3.4.dist-info/METADATA,sha256=gtyRqOMLEKdmaBoKGd220JkW3pRXZX8a7Etz2kcmhh0,4919
47
- icmd_python-3.3.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
48
- icmd_python-3.3.4.dist-info/entry_points.txt,sha256=CFX537PlyyPQL3Q_3TKUFeiiDSkOAboqaLEy9AIEwVU,39
49
- icmd_python-3.3.4.dist-info/top_level.txt,sha256=olzM6Z_QEwg7RrFVOnkgO0I2Ps2m7vhSqYQZYeiawcw,5
50
- icmd_python-3.3.4.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,,