calkit-python 0.41.4__py3-none-any.whl → 0.41.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.
Files changed (24) hide show
  1. calkit/cli/cloud.py +15 -77
  2. calkit/cloud.py +190 -6
  3. calkit/tests/cli/test_cloud.py +18 -8
  4. calkit/tests/test_cloud.py +76 -0
  5. {calkit_python-0.41.4.dist-info → calkit_python-0.41.5.dist-info}/METADATA +1 -1
  6. {calkit_python-0.41.4.dist-info → calkit_python-0.41.5.dist-info}/RECORD +24 -24
  7. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  8. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/install.json +0 -0
  9. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  10. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  11. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  12. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
  13. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
  14. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
  15. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -0
  16. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
  17. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
  18. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
  19. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js +0 -0
  20. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  21. {calkit_python-0.41.4.data → calkit_python-0.41.5.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  22. {calkit_python-0.41.4.dist-info → calkit_python-0.41.5.dist-info}/WHEEL +0 -0
  23. {calkit_python-0.41.4.dist-info → calkit_python-0.41.5.dist-info}/entry_points.txt +0 -0
  24. {calkit_python-0.41.4.dist-info → calkit_python-0.41.5.dist-info}/licenses/LICENSE +0 -0
calkit/cli/cloud.py CHANGED
@@ -2,9 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import socket
6
- import time
7
- import webbrowser
8
5
  from typing import Annotated
9
6
 
10
7
  import typer
@@ -48,79 +45,20 @@ def login(
48
45
  """
49
46
  from requests.exceptions import HTTPError
50
47
 
51
- try:
52
- calkit.cloud.get("/user")
53
- calkit.echo("Authenticated successfully ✅")
54
- if not force:
55
- return
56
- except (ValueError, HTTPError) as e:
57
- if isinstance(e, HTTPError) and "401" not in str(e):
58
- raise_error(str(e))
59
- # Now perform the OAuth device flow
60
- try:
61
- hostname = socket.gethostname()
62
- except Exception:
63
- hostname = None
64
- try:
65
- calkit.echo("Initiating device login flow")
66
- resp = calkit.cloud.post(
67
- "/login/device",
68
- json={"hostname": hostname},
69
- auth=False,
70
- )
71
- device_code = resp["device_code"]
72
- verification_uri = resp["verification_uri"]
73
- expires_in = int(resp["expires_in"])
74
- interval = int(resp["interval"])
75
- except Exception as e:
76
- raise_error(f"Failed to initiate device login flow: {e}")
77
- calkit.echo("Authorize this device by opening this URL:")
78
- calkit.echo(verification_uri)
79
- calkit.echo("Waiting for authorization")
80
- try:
81
- webbrowser.open(verification_uri)
82
- except Exception:
83
- # If auto-open fails, user can still copy-paste the URL.
84
- pass
85
- deadline = time.monotonic() + expires_in
86
- while time.monotonic() < deadline:
48
+ if not force:
87
49
  try:
88
- token_resp = calkit.cloud.post(
89
- "/login/device/token",
90
- json={"device_code": device_code},
91
- auth=False,
92
- )
93
- except Exception as e:
94
- txt = str(e)
95
- if "Device code has expired" in txt:
96
- raise_error(
97
- "Device code has expired; Run 'calkit cloud login' again"
98
- )
99
- if "Device code not found" in txt:
100
- raise_error(
101
- "Device code not found; Run 'calkit cloud login' again"
102
- )
103
- raise_error(f"Error while polling for device authorization: {e}")
104
- access_token = token_resp.get("access_token")
105
- if access_token:
106
- refresh_token = token_resp.get("refresh_token")
107
- try:
108
- cfg = calkit.config.read()
109
- cfg.access_token = access_token
110
- if refresh_token:
111
- cfg.refresh_token = refresh_token
112
- cfg.write()
113
- calkit.cloud._tokens[calkit.cloud.get_base_url()] = (
114
- access_token
115
- )
116
- except Exception as e:
117
- raise_error(f"Failed to save token in config: {e}")
118
- calkit.echo("Logged in successfully ✅")
50
+ calkit.cloud.get("/user")
51
+ calkit.echo("Authenticated successfully ✅")
119
52
  return
120
- sleep_seconds = min(interval, max(0.0, deadline - time.monotonic()))
121
- if sleep_seconds > 0:
122
- time.sleep(sleep_seconds)
123
- raise_error(
124
- "Timed out waiting for device authorization; "
125
- "Run 'calkit cloud login' again"
126
- )
53
+ except (ValueError, HTTPError) as e:
54
+ # Any auth failure (no token, 401, 403) falls through to the
55
+ # device flow so the user can re-authenticate. Other HTTP errors
56
+ # (e.g. 5xx) are surfaced.
57
+ if isinstance(e, HTTPError) and not any(
58
+ code in str(e) for code in ("401", "403")
59
+ ):
60
+ raise_error(str(e))
61
+ try:
62
+ calkit.cloud.run_device_flow()
63
+ except calkit.cloud.DeviceLoginError as e:
64
+ raise_error(str(e))
calkit/cloud.py CHANGED
@@ -6,8 +6,11 @@ import base64
6
6
  import json
7
7
  import logging
8
8
  import os
9
+ import socket
10
+ import sys
9
11
  import threading
10
12
  import time
13
+ import webbrowser
11
14
  from functools import partial
12
15
  from typing import Literal
13
16
 
@@ -28,6 +31,123 @@ _refresh_lock = threading.Lock()
28
31
  # Seconds before JWT expiry at which we proactively refresh.
29
32
  _REFRESH_BUFFER_SECONDS = 60
30
33
 
34
+ # Serializes interactive device-login attempts so that, when several
35
+ # concurrent requests all hit an auth failure, only one of them actually
36
+ # prompts the user; the rest wait on the lock and then reuse the freshly
37
+ # minted token. The device-flow's own polling calls use ``auth=False`` so
38
+ # they don't recurse into the auth-retry paths in ``_request``.
39
+ _device_login_lock = threading.Lock()
40
+
41
+
42
+ class DeviceLoginError(RuntimeError):
43
+ """Raised when the OAuth device login flow cannot complete."""
44
+
45
+
46
+ def run_device_flow() -> str:
47
+ """Run the OAuth device login flow and return the new access token.
48
+
49
+ On success the token is written to the config file and the in-memory
50
+ token cache. Raises :class:`DeviceLoginError` on any failure (server
51
+ error, expired/missing device code, timeout, etc.).
52
+ """
53
+ # Serialize concurrent attempts (e.g. multiple fsspec threads all hitting
54
+ # 403 at the same time): the first thread runs the flow, the rest wait
55
+ # and then reuse the freshly minted token.
56
+ base_url = get_base_url()
57
+ token_before = _tokens.get(base_url)
58
+ with _device_login_lock:
59
+ token_after = _tokens.get(base_url)
60
+ # If the cached token changed while we were waiting on the lock,
61
+ # another thread just completed a device flow — reuse its result.
62
+ if token_after is not None and token_after != token_before:
63
+ return token_after
64
+ try:
65
+ hostname = socket.gethostname()
66
+ except Exception:
67
+ hostname = None
68
+ print("Initiating device login flow", flush=True)
69
+ try:
70
+ resp = post(
71
+ "/login/device",
72
+ json={"hostname": hostname},
73
+ auth=False,
74
+ )
75
+ device_code = resp["device_code"]
76
+ verification_uri = resp["verification_uri"]
77
+ expires_in = int(resp["expires_in"])
78
+ interval = int(resp["interval"])
79
+ except Exception as e:
80
+ raise DeviceLoginError(
81
+ f"Failed to initiate device login flow: {e}"
82
+ ) from e
83
+ print("Authorize this device by opening this URL:", flush=True)
84
+ print(verification_uri, flush=True)
85
+ print("Waiting for authorization", flush=True)
86
+ try:
87
+ webbrowser.open(verification_uri)
88
+ except Exception:
89
+ pass
90
+ deadline = time.monotonic() + expires_in
91
+ while time.monotonic() < deadline:
92
+ try:
93
+ token_resp = post(
94
+ "/login/device/token",
95
+ json={"device_code": device_code},
96
+ auth=False,
97
+ )
98
+ except Exception as e:
99
+ txt = str(e)
100
+ if "Device code has expired" in txt:
101
+ raise DeviceLoginError(
102
+ "Device code has expired; "
103
+ "Run 'calkit cloud login' again"
104
+ ) from e
105
+ if "Device code not found" in txt:
106
+ raise DeviceLoginError(
107
+ "Device code not found; "
108
+ "Run 'calkit cloud login' again"
109
+ ) from e
110
+ raise DeviceLoginError(
111
+ f"Error while polling for device authorization: {e}"
112
+ ) from e
113
+ access_token = token_resp.get("access_token")
114
+ if access_token:
115
+ refresh_token = token_resp.get("refresh_token")
116
+ try:
117
+ cfg = config.read()
118
+ cfg.access_token = access_token
119
+ if refresh_token:
120
+ cfg.refresh_token = refresh_token
121
+ # A stored PAT (``token``) takes priority over
122
+ # ``access_token`` in get_token(), so a stale PAT would
123
+ # keep being used by future processes and re-trigger
124
+ # the device flow every time. The user just
125
+ # re-authenticated, so device-flow credentials win.
126
+ if getattr(cfg, "token", None) is not None:
127
+ cfg.token = None
128
+ # A stored DVC token may have been revoked alongside
129
+ # the access token that just failed; clear it so the
130
+ # next remote-auth check mints a fresh one.
131
+ if getattr(cfg, "dvc_token", None) is not None:
132
+ cfg.dvc_token = None
133
+ cfg.write()
134
+ _tokens[base_url] = access_token
135
+ except Exception as e:
136
+ raise DeviceLoginError(
137
+ f"Failed to save token in config: {e}"
138
+ ) from e
139
+ print("Logged in successfully ✅", flush=True)
140
+ return access_token
141
+ sleep_seconds = min(
142
+ interval, max(0.0, deadline - time.monotonic())
143
+ )
144
+ if sleep_seconds > 0:
145
+ time.sleep(sleep_seconds)
146
+ raise DeviceLoginError(
147
+ "Timed out waiting for device authorization; "
148
+ "Run 'calkit cloud login' again"
149
+ )
150
+
31
151
 
32
152
  def get_base_url() -> str:
33
153
  """Get the API base URL."""
@@ -188,28 +308,92 @@ def _request(
188
308
  if base_url is None:
189
309
  base_url = get_base_url()
190
310
  refresh_attempted = False
311
+ device_login_attempted = False
312
+ # We may prompt the user via the device login flow only when stdin and
313
+ # stdout are TTYs (i.e. not in CI, daemons, or piped subprocesses) and
314
+ # the user hasn't opted out via the env var.
315
+ can_prompt = auth and not os.environ.get("CALKIT_NO_INTERACTIVE_LOGIN")
316
+ if can_prompt:
317
+ try:
318
+ can_prompt = sys.stdin.isatty() and sys.stdout.isatty()
319
+ except Exception:
320
+ can_prompt = False
191
321
  for retry_num in range(max_retries + 1):
322
+ try:
323
+ req_headers = get_headers(headers, auth=auth)
324
+ except ValueError:
325
+ # No token in config at all. If we can prompt the user, run
326
+ # the device flow and try again; otherwise re-raise so callers
327
+ # see the original "no token" error. ``run_device_flow``
328
+ # serializes concurrent callers via ``_device_login_lock`` and
329
+ # reuses the freshly minted token, so threads that arrive
330
+ # mid-flow simply wait their turn rather than re-prompting.
331
+ if can_prompt and not device_login_attempted:
332
+ device_login_attempted = True
333
+ run_device_flow()
334
+ continue
335
+ raise
192
336
  resp = func(
193
337
  base_url + path,
194
338
  params=params,
195
339
  json=json,
196
340
  data=data,
197
- headers=get_headers(headers, auth=auth),
341
+ headers=req_headers,
198
342
  **kwargs,
199
343
  )
200
344
  if resp.status_code == 502 and retry_num < max_retries:
201
345
  wait = min(base_delay_seconds * (2**retry_num), max_delay_seconds)
202
346
  time.sleep(wait)
203
347
  continue
204
- # On 401, attempt a token refresh once (only for access_token sessions,
205
- # not PATs _try_refresh returns None when no refresh_token is stored).
206
- # get_headers() re-calls get_token() on the next iteration, so it will
207
- # automatically pick up the new token stored in _tokens by _try_refresh.
208
- if resp.status_code == 401 and auth and not refresh_attempted:
348
+ # Decide whether this response is a credential rejection (vs. a
349
+ # permission/authorization failure that happens to also be 403).
350
+ # 401 is always a credential rejection. For 403, only treat it as
351
+ # one if the API's ``detail`` identifies it as such — otherwise
352
+ # we'd hijack legitimate "you can't do that" errors and pop a
353
+ # browser-based login flow at the user.
354
+ looks_like_auth_failure = auth and resp.status_code == 401
355
+ if auth and resp.status_code == 403:
356
+ try:
357
+ detail = resp.json().get("detail")
358
+ except Exception:
359
+ detail = None
360
+ looks_like_auth_failure = isinstance(detail, str) and any(
361
+ s in detail
362
+ for s in (
363
+ "Could not validate credentials",
364
+ "Not authenticated",
365
+ "Invalid token",
366
+ "Token has expired",
367
+ )
368
+ )
369
+ # On credential rejection, attempt a token refresh once.
370
+ # ``_try_refresh`` returns ``None`` when no refresh token is stored
371
+ # (e.g. PAT-only sessions). ``get_headers()`` re-calls
372
+ # ``get_token()`` on the next iteration, so it will automatically
373
+ # pick up the new token stored in ``_tokens`` by ``_try_refresh``.
374
+ if looks_like_auth_failure and not refresh_attempted:
209
375
  refresh_attempted = True
210
376
  new_token = _try_refresh()
211
377
  if new_token is not None:
212
378
  continue
379
+ # If refresh didn't help, fall back to the interactive device login
380
+ # flow once. This covers both HTTP remotes (where /user/tokens
381
+ # 401/403s) and ck:// remotes (which go through this same
382
+ # ``_request`` path). ``run_device_flow`` serializes via
383
+ # ``_device_login_lock`` and reuses the new token, so concurrent
384
+ # callers wait rather than each starting their own flow.
385
+ if (
386
+ looks_like_auth_failure
387
+ and can_prompt
388
+ and not device_login_attempted
389
+ ):
390
+ device_login_attempted = True
391
+ try:
392
+ run_device_flow()
393
+ except DeviceLoginError as e:
394
+ logger.warning("Device login failed: %s", e)
395
+ else:
396
+ continue
213
397
  try:
214
398
  resp.raise_for_status()
215
399
  except HTTPError as e:
@@ -62,8 +62,12 @@ def test_cloud_login_device_flow_success(monkeypatch, capsys):
62
62
  monkeypatch.setattr(cloud_cli.calkit.cloud, "get", _get)
63
63
  monkeypatch.setattr(cloud_cli.calkit.cloud, "post", _post)
64
64
  monkeypatch.setattr(cloud_cli.calkit.config, "read", lambda: cfg)
65
- monkeypatch.setattr(cloud_cli.webbrowser, "open", lambda _url: True)
66
- monkeypatch.setattr(cloud_cli.time, "sleep", lambda _seconds: None)
65
+ monkeypatch.setattr(
66
+ cloud_cli.calkit.cloud.webbrowser, "open", lambda _url: True
67
+ )
68
+ monkeypatch.setattr(
69
+ cloud_cli.calkit.cloud.time, "sleep", lambda _seconds: None
70
+ )
67
71
  cloud_cli.login()
68
72
  out = capsys.readouterr().out
69
73
  assert "Authorize this device by opening this URL:" in out
@@ -114,8 +118,10 @@ def test_cloud_login_force_re_authenticates(monkeypatch, capsys):
114
118
  monkeypatch.setattr(cloud_cli.calkit.cloud, "get", _get)
115
119
  monkeypatch.setattr(cloud_cli.calkit.cloud, "post", _post)
116
120
  monkeypatch.setattr(cloud_cli.calkit.config, "read", lambda: cfg)
117
- monkeypatch.setattr(cloud_cli.webbrowser, "open", lambda _url: True)
118
- monkeypatch.setattr(cloud_cli.time, "sleep", lambda _s: None)
121
+ monkeypatch.setattr(
122
+ cloud_cli.calkit.cloud.webbrowser, "open", lambda _url: True
123
+ )
124
+ monkeypatch.setattr(cloud_cli.calkit.cloud.time, "sleep", lambda _s: None)
119
125
  cloud_cli.login(force=True)
120
126
  out = capsys.readouterr().out
121
127
  assert "Logged in successfully" in out
@@ -140,8 +146,10 @@ def test_cloud_login_device_code_expired(monkeypatch):
140
146
 
141
147
  monkeypatch.setattr(cloud_cli.calkit.cloud, "get", _get)
142
148
  monkeypatch.setattr(cloud_cli.calkit.cloud, "post", _post)
143
- monkeypatch.setattr(cloud_cli.webbrowser, "open", lambda _url: True)
144
- monkeypatch.setattr(cloud_cli.time, "sleep", lambda _s: None)
149
+ monkeypatch.setattr(
150
+ cloud_cli.calkit.cloud.webbrowser, "open", lambda _url: True
151
+ )
152
+ monkeypatch.setattr(cloud_cli.calkit.cloud.time, "sleep", lambda _s: None)
145
153
  with pytest.raises(typer.Exit):
146
154
  cloud_cli.login()
147
155
 
@@ -164,7 +172,9 @@ def test_cloud_login_device_code_not_found(monkeypatch):
164
172
 
165
173
  monkeypatch.setattr(cloud_cli.calkit.cloud, "get", _get)
166
174
  monkeypatch.setattr(cloud_cli.calkit.cloud, "post", _post)
167
- monkeypatch.setattr(cloud_cli.webbrowser, "open", lambda _url: True)
168
- monkeypatch.setattr(cloud_cli.time, "sleep", lambda _s: None)
175
+ monkeypatch.setattr(
176
+ cloud_cli.calkit.cloud.webbrowser, "open", lambda _url: True
177
+ )
178
+ monkeypatch.setattr(cloud_cli.calkit.cloud.time, "sleep", lambda _s: None)
169
179
  with pytest.raises(typer.Exit):
170
180
  cloud_cli.login()
@@ -238,6 +238,82 @@ def test_request_retries_on_401_with_refresh(monkeypatch):
238
238
  assert call_count["n"] == 2
239
239
 
240
240
 
241
+ def test_request_invalid_credentials_403_triggers_refresh(monkeypatch):
242
+ """A 403 whose detail says credentials are invalid should be treated
243
+ like a 401 — refresh attempted, request retried."""
244
+ base_url = cloud.get_base_url()
245
+ fresh = _make_jwt(time.time() + 3600)
246
+ call_count = {"n": 0}
247
+
248
+ class Resp403:
249
+ status_code = 403
250
+
251
+ def raise_for_status(self):
252
+ from requests.exceptions import HTTPError
253
+
254
+ raise HTTPError("403")
255
+
256
+ def json(self):
257
+ return {"detail": "Could not validate credentials"}
258
+
259
+ class Resp200:
260
+ status_code = 200
261
+
262
+ def raise_for_status(self):
263
+ pass
264
+
265
+ def json(self):
266
+ return {"ok": True}
267
+
268
+ def _fake_get(url, **kwargs):
269
+ call_count["n"] += 1
270
+ return Resp403() if call_count["n"] == 1 else Resp200()
271
+
272
+ monkeypatch.setattr(cloud.requests, "get", _fake_get)
273
+ monkeypatch.setitem(cloud._tokens, base_url, fresh)
274
+ monkeypatch.setattr(cloud, "_try_refresh", lambda: fresh)
275
+ result = cloud._request("get", "/test", base_url=base_url)
276
+ assert result == {"ok": True}
277
+ assert call_count["n"] == 2
278
+
279
+
280
+ def test_request_permission_403_does_not_trigger_refresh(monkeypatch):
281
+ """A 403 that's a real permission denial (e.g. user not allowed to
282
+ create a project under an org) must surface as an HTTPError, not
283
+ silently trigger refresh/device login."""
284
+ base_url = cloud.get_base_url()
285
+ fresh = _make_jwt(time.time() + 3600)
286
+ call_count = {"n": 0}
287
+ refresh_calls = {"n": 0}
288
+
289
+ class Resp403Perm:
290
+ status_code = 403
291
+
292
+ def raise_for_status(self):
293
+ from requests.exceptions import HTTPError
294
+
295
+ raise HTTPError("403")
296
+
297
+ def json(self):
298
+ return {"detail": "You are not allowed to create projects here"}
299
+
300
+ def _fake_post(url, **kwargs):
301
+ call_count["n"] += 1
302
+ return Resp403Perm()
303
+
304
+ def _fake_refresh():
305
+ refresh_calls["n"] += 1
306
+ return fresh
307
+
308
+ monkeypatch.setattr(cloud.requests, "post", _fake_post)
309
+ monkeypatch.setitem(cloud._tokens, base_url, fresh)
310
+ monkeypatch.setattr(cloud, "_try_refresh", _fake_refresh)
311
+ with pytest.raises(Exception):
312
+ cloud._request("post", "/projects", base_url=base_url)
313
+ assert call_count["n"] == 1
314
+ assert refresh_calls["n"] == 0
315
+
316
+
241
317
  def test_concurrent_refresh_fires_only_once(monkeypatch):
242
318
  """Many threads calling get_token() on an expiring JWT should trigger
243
319
  exactly one refresh request, not one per thread."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: calkit-python
3
- Version: 0.41.4
3
+ Version: 0.41.5
4
4
  Summary: Reproducibility simplified.
5
5
  Project-URL: Homepage, https://calkit.org
6
6
  Project-URL: Issues, https://github.com/calkit/calkit/issues
@@ -2,7 +2,7 @@ calkit/__init__.py,sha256=mJqFPNUPG6v4rrOXfZzNfjpjAdjOurlEQ9iLsBPzsBA,2602
2
2
  calkit/__main__.py,sha256=whqS7I7q_c9004emvKptZ9B9OVn-jgr5kaLaCFKmwa0,112
3
3
  calkit/calc.py,sha256=ucTWBssZhDDc585q2R6l5Zb-924vxwGID4KX7K-eUPA,7563
4
4
  calkit/check.py,sha256=VVJErHeCzX-bcdRtu0dgMpLLMU7M38SZI_wqyMgdzwg,9080
5
- calkit/cloud.py,sha256=mudtHkCuCbciQ5xwTjNeqRl0jGBSR-BzxbWq3zd-aSg,7638
5
+ calkit/cloud.py,sha256=65380HbUVEINxXue9YZnbOX5PsnszdgR9ZCli0DR2Wg,15772
6
6
  calkit/conda.py,sha256=Jb1Fz3jwkk4SZ0NgoWXd4lf50gEC8d8U87ElrYjdiwk,30752
7
7
  calkit/config.py,sha256=tkrdAQU1m3__B9efpYgK6IhtLtS3WWFcVpY2zEQpBio,7573
8
8
  calkit/core.py,sha256=6yfW89FJ8N0NkYSUW17C3NEsobtT-yOKWh7ukXNaMQs,27441
@@ -31,7 +31,7 @@ calkit/releases.py,sha256=BNS1K8yYf7YPZpeLQV7qP4twb-4hF0Jk7Zhm2qX8IsM,9838
31
31
  calkit/server.py,sha256=GQm1bI5Q1NLFNwQL-iP6j4iWKUpKpP3cLEyfj5IMwe4,22009
32
32
  calkit/cli/__init__.py,sha256=pP_1wFuewdUUvEWTtaEcdKblPaSYuTvthQb2Ef08UMo,1921
33
33
  calkit/cli/check.py,sha256=RgFCY1xrqY5Hl2iSH7aA2ybyDpYfk7e65HntNjngN3M,49979
34
- calkit/cli/cloud.py,sha256=3FVvJUqG16I2PWIpcbvnVryK9tpqSK06BwPQBN_kvQs,4040
34
+ calkit/cli/cloud.py,sha256=mMFRRFvo8MrzxQ-B_WTCSI8-erAWnFzgN2Hd0kVvB24,1815
35
35
  calkit/cli/config.py,sha256=cTNEM0AmwTPmxWcnXdWp4VdO_073ye74bLcuXSttNsQ,10340
36
36
  calkit/cli/core.py,sha256=oICn4rLv9316X5bThD1Wrcc_qdmBHqye8NE4dIwojmk,3031
37
37
  calkit/cli/describe.py,sha256=bCq-YPiWSM4nDyi6KJXBPqiGjD2eW3xh9xY2oaAK3LQ,347
@@ -71,7 +71,7 @@ calkit/templates/latex/jfm/upmath.sty,sha256=9nzNixqQd18zVrv0vLBjGjfnc8L19tP_m0b
71
71
  calkit/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
72
72
  calkit/tests/test_calc.py,sha256=h-wOUIRqfyi06LTzhY_rS-a1xChZTkmwdEfBybMUM7E,2033
73
73
  calkit/tests/test_check.py,sha256=ngRZq1_dSRQoa0oSjcx_QpR94omG9Bpas_h0wqjwrK0,1475
74
- calkit/tests/test_cloud.py,sha256=WKDaLmmsnSyjgBEQFTaP1VpJhrS44pYY7KLzl_GwvE4,7877
74
+ calkit/tests/test_cloud.py,sha256=583oy2zH0fLexBBB_cmrxCgu7qsouBe7tKY-kkbzhXE,10227
75
75
  calkit/tests/test_conda.py,sha256=t-Oy1mL3Vb5njj_t3LQ8HwMhsmLN64HAzSlebnN1A9s,14204
76
76
  calkit/tests/test_core.py,sha256=MDSQCsUI4RkZ6VZGWhDELdebZujLuLn8o6BLBSaBb5w,4607
77
77
  calkit/tests/test_dependencies.py,sha256=HskXLzbvvRJz2TXnKPpPAo9lPhoZGYQFy4BmEF31nvk,11139
@@ -92,7 +92,7 @@ calkit/tests/test_releases.py,sha256=-eig7SpntQKjHLBRbHv0J-pX4ecr3HO5dukuvfZQPIo
92
92
  calkit/tests/test_templates.py,sha256=RN4RIgmuFlmFYQFgfJUYS-KaLmie8fnWzmfg5Ay1egk,430
93
93
  calkit/tests/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
94
94
  calkit/tests/cli/test_check.py,sha256=TPdG1R-h7Byinsln9n3WegRuBWN0bHltuRcOROCZhwQ,6767
95
- calkit/tests/cli/test_cloud.py,sha256=cN9BSl9JuYWxp4MQQh9scKrndTh9OGhJPQLV5IL-Nq0,5826
95
+ calkit/tests/cli/test_cloud.py,sha256=ZWTExR1R5V_CL6EO2QPVk-_iZrPLtYTbUkQwyH8ZHKc,6000
96
96
  calkit/tests/cli/test_config.py,sha256=Tl4LWfi81eQo6fUgJsoZRNPj_LfkKH-s42HRg5Mxh4c,2571
97
97
  calkit/tests/cli/test_import.py,sha256=e2BToVPebf4NalFQuIgyTNJHwbc9VMc6AYO-FAhYbQ0,663
98
98
  calkit/tests/cli/test_latex.py,sha256=SqDbenoFtnsgkfqFjR2Jhw6TJsYQBtLqIGjM3PJmddc,3604
@@ -117,23 +117,23 @@ calkit/tests/models/test_pipeline.py,sha256=AJsMAX4928U3_Omt0_8K96U5LU_KoNErgpHe
117
117
  calkit/agent_skills/add-pipeline-stage/SKILL.md,sha256=kpYYb-rJsS4B9p1Ub3gL21irCG5ZZ4DvWZpdJeEwPZk,3772
118
118
  calkit/agent_skills/conventions/SKILL.md,sha256=nAM2FjSMk6ED56Dr5zh2bL_dhfzDra-h2ZgzHU7ruS4,9524
119
119
  calkit/agent_skills/create-pipeline/SKILL.md,sha256=2FGw1iDQVRk5FUq79GP3lPdgvgof2Wi1O9O-abGGtgs,5422
120
- calkit_python-0.41.4.data/data/etc/jupyter/jupyter_server_config.d/calkit.json,sha256=CWrZP--JDGz8fsvbAlKr_duTL1vDriGT48SL1YCtkY0,81
121
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/package.json,sha256=INxx8QhvSl7KP6yL7A7-m2-FIsnB__mYEWG_Eq5RP-c,6651
122
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig,sha256=6w0b5y8LztEv2tErajJ5d39yBryld02crX0CG3zZFNs,6509
123
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json,sha256=YSpIrwpwB-lQBk9Mwv-npedWTOOZreO6nlWZhqlWyGI,840
124
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js,sha256=mixXcqFUZukj7_jQVxHTavsYl7cdZv83sEe4phJgkh0,59893
125
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js,sha256=LEEAOkUtQ9KzWEHn-QFv5yGi70B-sBOnrvztzHr0Shw,223
126
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js,sha256=pCoEaqUQj1T4-zAc9SUd__9ZGm7uL2wHQve2xwL89NI,8156
127
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js,sha256=4_nMNAiDSn_cw7UjIHEwone4fizrvqrqSgbwUWvjmoU,114571
128
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js,sha256=n_a0yu_gE7l6fauyoXXv9BZ7ZEYt2387mTfs7CbLcs4,51939
129
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt,sha256=eNJ8gc9n9IF8nW1d9sI9niuHstYzjNz5vqXx9UgWSPc,249
130
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png,sha256=svHD7-cMtTnRITFwugwsVaB9nZ-h8A61ose8z32HiRE,24850
131
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js,sha256=ZUaa-ZbnqWqpgxIpcQcRX7auvhOBkPH2YHNID5udGQs,8737
132
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/static/style.js,sha256=r89Jlk5v1drcwhCpv9FnC3Jig_JH4k24kZNIvxh6Htk,149
133
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json,sha256=2BGdItLJwO3fAopLl4eJvp26TEwuscYC0-yFaF-LaLU,13683
134
- calkit_python-0.41.4.data/data/share/jupyter/labextensions/calkit/install.json,sha256=DK9d8G-q-rMVlcT3rAeGIyo3REWKw6FySBZceLU9yaw,187
135
- calkit_python-0.41.4.dist-info/METADATA,sha256=W5T03nVqGhjZo7IaJb097fUXquL7WQJKBDOOJ9aBJKU,12070
136
- calkit_python-0.41.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
137
- calkit_python-0.41.4.dist-info/entry_points.txt,sha256=2iQBBzjTAOdk66CwS-f3ecXXW5DjUqkG1KBRj8mHI1I,133
138
- calkit_python-0.41.4.dist-info/licenses/LICENSE,sha256=9ZamCaSUTZk9rcrnf-sWFKLOHr3ws-S_dgKMegW4nw8,1056
139
- calkit_python-0.41.4.dist-info/RECORD,,
120
+ calkit_python-0.41.5.data/data/etc/jupyter/jupyter_server_config.d/calkit.json,sha256=CWrZP--JDGz8fsvbAlKr_duTL1vDriGT48SL1YCtkY0,81
121
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/package.json,sha256=INxx8QhvSl7KP6yL7A7-m2-FIsnB__mYEWG_Eq5RP-c,6651
122
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig,sha256=6w0b5y8LztEv2tErajJ5d39yBryld02crX0CG3zZFNs,6509
123
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json,sha256=YSpIrwpwB-lQBk9Mwv-npedWTOOZreO6nlWZhqlWyGI,840
124
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js,sha256=mixXcqFUZukj7_jQVxHTavsYl7cdZv83sEe4phJgkh0,59893
125
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js,sha256=LEEAOkUtQ9KzWEHn-QFv5yGi70B-sBOnrvztzHr0Shw,223
126
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js,sha256=pCoEaqUQj1T4-zAc9SUd__9ZGm7uL2wHQve2xwL89NI,8156
127
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js,sha256=4_nMNAiDSn_cw7UjIHEwone4fizrvqrqSgbwUWvjmoU,114571
128
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js,sha256=n_a0yu_gE7l6fauyoXXv9BZ7ZEYt2387mTfs7CbLcs4,51939
129
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt,sha256=eNJ8gc9n9IF8nW1d9sI9niuHstYzjNz5vqXx9UgWSPc,249
130
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png,sha256=svHD7-cMtTnRITFwugwsVaB9nZ-h8A61ose8z32HiRE,24850
131
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js,sha256=ZUaa-ZbnqWqpgxIpcQcRX7auvhOBkPH2YHNID5udGQs,8737
132
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/static/style.js,sha256=r89Jlk5v1drcwhCpv9FnC3Jig_JH4k24kZNIvxh6Htk,149
133
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json,sha256=2BGdItLJwO3fAopLl4eJvp26TEwuscYC0-yFaF-LaLU,13683
134
+ calkit_python-0.41.5.data/data/share/jupyter/labextensions/calkit/install.json,sha256=DK9d8G-q-rMVlcT3rAeGIyo3REWKw6FySBZceLU9yaw,187
135
+ calkit_python-0.41.5.dist-info/METADATA,sha256=nlqF81x6hGAVq-2v7BjeUGAcOdkFZUyUpV73jXYywp8,12070
136
+ calkit_python-0.41.5.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
137
+ calkit_python-0.41.5.dist-info/entry_points.txt,sha256=2iQBBzjTAOdk66CwS-f3ecXXW5DjUqkG1KBRj8mHI1I,133
138
+ calkit_python-0.41.5.dist-info/licenses/LICENSE,sha256=9ZamCaSUTZk9rcrnf-sWFKLOHr3ws-S_dgKMegW4nw8,1056
139
+ calkit_python-0.41.5.dist-info/RECORD,,