calkit-python 0.41.4__py3-none-any.whl → 0.41.6__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.
- calkit/cli/cloud.py +15 -77
- calkit/cloud.py +190 -6
- calkit/pipeline.py +5 -1
- calkit/tests/cli/test_cloud.py +18 -8
- calkit/tests/test_cloud.py +76 -0
- calkit/tests/test_pipeline.py +5 -0
- {calkit_python-0.41.4.dist-info → calkit_python-0.41.6.dist-info}/METADATA +1 -1
- {calkit_python-0.41.4.dist-info → calkit_python-0.41.6.dist-info}/RECORD +26 -26
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/install.json +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.41.4.data → calkit_python-0.41.6.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.41.4.dist-info → calkit_python-0.41.6.dist-info}/WHEEL +0 -0
- {calkit_python-0.41.4.dist-info → calkit_python-0.41.6.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.41.4.dist-info → calkit_python-0.41.6.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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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=
|
|
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
|
-
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
|
|
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:
|
calkit/pipeline.py
CHANGED
|
@@ -1273,7 +1273,11 @@ def get_matrix_item_targets(
|
|
|
1273
1273
|
|
|
1274
1274
|
repo = calkit.dvc.get_dvc_repo(wdir)
|
|
1275
1275
|
prefix = stage_name + "@"
|
|
1276
|
-
items = [
|
|
1276
|
+
items = [
|
|
1277
|
+
s
|
|
1278
|
+
for s in repo.index.stages
|
|
1279
|
+
if (getattr(s, "name", None) or "").startswith(prefix)
|
|
1280
|
+
]
|
|
1277
1281
|
item_set = set(items)
|
|
1278
1282
|
upstreams: set = set()
|
|
1279
1283
|
for item in items:
|
calkit/tests/cli/test_cloud.py
CHANGED
|
@@ -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(
|
|
66
|
-
|
|
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(
|
|
118
|
-
|
|
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(
|
|
144
|
-
|
|
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(
|
|
168
|
-
|
|
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()
|
calkit/tests/test_cloud.py
CHANGED
|
@@ -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."""
|
calkit/tests/test_pipeline.py
CHANGED
|
@@ -2034,6 +2034,11 @@ def test_get_matrix_item_targets(tmp_dir):
|
|
|
2034
2034
|
}
|
|
2035
2035
|
with open("dvc.yaml", "w") as f:
|
|
2036
2036
|
calkit.ryaml.dump(dvc_yaml, f)
|
|
2037
|
+
# A single-file `.dvc` stage exposes a base `Stage` with no `name` attr;
|
|
2038
|
+
# the matrix lookup must skip it instead of raising AttributeError.
|
|
2039
|
+
with open("tracked.txt", "w") as f:
|
|
2040
|
+
f.write("tracked\n")
|
|
2041
|
+
subprocess.check_call([sys.executable, "-m", "dvc", "add", "tracked.txt"])
|
|
2037
2042
|
items, upstreams = calkit.pipeline.get_matrix_item_targets("work")
|
|
2038
2043
|
# Each iteration is addressable; the shared upstream is reported so the
|
|
2039
2044
|
# caller can build it before fanning the items out concurrently. Results
|
|
@@ -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=
|
|
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
|
|
@@ -26,12 +26,12 @@ calkit/notebooks.py,sha256=obKRJaVf-rrQslcu76yx8lP18QKWGJOD_Irw7yHw9ek,8913
|
|
|
26
26
|
calkit/office.py,sha256=mZqCxkIsmWthBAeJZ5SiGe21fn5zD2zXsy1E6nQIpWE,1291
|
|
27
27
|
calkit/ops.py,sha256=lLlZAFeplU8uLeMavr99d__Iof-YYwKijus74WfLipk,854
|
|
28
28
|
calkit/overleaf.py,sha256=rii_3tazZsSQirgnTU0KbB_TuYt8nAs2MNOGY88HWpk,23600
|
|
29
|
-
calkit/pipeline.py,sha256=
|
|
29
|
+
calkit/pipeline.py,sha256=8CCWwVKGKUEc16Sh_lhFvPeeo98RPtZeX6b71zOm1To,57604
|
|
30
30
|
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=
|
|
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=
|
|
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
|
|
@@ -87,12 +87,12 @@ calkit/tests/test_jupyter.py,sha256=YTL6zI740UM2KUjskSipzvSKxsyQ8rVPFQIX5cb2tMQ,
|
|
|
87
87
|
calkit/tests/test_magics.py,sha256=1O_hYPLMBVQJj8rLCFV3sU2DejlLLnk6Tv4_Tr-FZHQ,2101
|
|
88
88
|
calkit/tests/test_matlab.py,sha256=7yFKOYDCtxYIkRyFOwTIxlvzx03Pac0FKGIUT4Zx7zA,7589
|
|
89
89
|
calkit/tests/test_notebooks.py,sha256=6xUpHjCbCv08oJkArj-NkTUMlz9zzm8pf1qam3s27M0,2692
|
|
90
|
-
calkit/tests/test_pipeline.py,sha256=
|
|
90
|
+
calkit/tests/test_pipeline.py,sha256=TYBzuywlHFjpwI7CZOoXKZZCroDWilSmjd4ZmTSE6OY,78119
|
|
91
91
|
calkit/tests/test_releases.py,sha256=-eig7SpntQKjHLBRbHv0J-pX4ecr3HO5dukuvfZQPIo,6081
|
|
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=
|
|
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.
|
|
121
|
-
calkit_python-0.41.
|
|
122
|
-
calkit_python-0.41.
|
|
123
|
-
calkit_python-0.41.
|
|
124
|
-
calkit_python-0.41.
|
|
125
|
-
calkit_python-0.41.
|
|
126
|
-
calkit_python-0.41.
|
|
127
|
-
calkit_python-0.41.
|
|
128
|
-
calkit_python-0.41.
|
|
129
|
-
calkit_python-0.41.
|
|
130
|
-
calkit_python-0.41.
|
|
131
|
-
calkit_python-0.41.
|
|
132
|
-
calkit_python-0.41.
|
|
133
|
-
calkit_python-0.41.
|
|
134
|
-
calkit_python-0.41.
|
|
135
|
-
calkit_python-0.41.
|
|
136
|
-
calkit_python-0.41.
|
|
137
|
-
calkit_python-0.41.
|
|
138
|
-
calkit_python-0.41.
|
|
139
|
-
calkit_python-0.41.
|
|
120
|
+
calkit_python-0.41.6.data/data/etc/jupyter/jupyter_server_config.d/calkit.json,sha256=CWrZP--JDGz8fsvbAlKr_duTL1vDriGT48SL1YCtkY0,81
|
|
121
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/package.json,sha256=INxx8QhvSl7KP6yL7A7-m2-FIsnB__mYEWG_Eq5RP-c,6651
|
|
122
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig,sha256=6w0b5y8LztEv2tErajJ5d39yBryld02crX0CG3zZFNs,6509
|
|
123
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json,sha256=YSpIrwpwB-lQBk9Mwv-npedWTOOZreO6nlWZhqlWyGI,840
|
|
124
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js,sha256=mixXcqFUZukj7_jQVxHTavsYl7cdZv83sEe4phJgkh0,59893
|
|
125
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js,sha256=LEEAOkUtQ9KzWEHn-QFv5yGi70B-sBOnrvztzHr0Shw,223
|
|
126
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js,sha256=pCoEaqUQj1T4-zAc9SUd__9ZGm7uL2wHQve2xwL89NI,8156
|
|
127
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js,sha256=4_nMNAiDSn_cw7UjIHEwone4fizrvqrqSgbwUWvjmoU,114571
|
|
128
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js,sha256=n_a0yu_gE7l6fauyoXXv9BZ7ZEYt2387mTfs7CbLcs4,51939
|
|
129
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt,sha256=eNJ8gc9n9IF8nW1d9sI9niuHstYzjNz5vqXx9UgWSPc,249
|
|
130
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png,sha256=svHD7-cMtTnRITFwugwsVaB9nZ-h8A61ose8z32HiRE,24850
|
|
131
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js,sha256=ZUaa-ZbnqWqpgxIpcQcRX7auvhOBkPH2YHNID5udGQs,8737
|
|
132
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/static/style.js,sha256=r89Jlk5v1drcwhCpv9FnC3Jig_JH4k24kZNIvxh6Htk,149
|
|
133
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json,sha256=2BGdItLJwO3fAopLl4eJvp26TEwuscYC0-yFaF-LaLU,13683
|
|
134
|
+
calkit_python-0.41.6.data/data/share/jupyter/labextensions/calkit/install.json,sha256=DK9d8G-q-rMVlcT3rAeGIyo3REWKw6FySBZceLU9yaw,187
|
|
135
|
+
calkit_python-0.41.6.dist-info/METADATA,sha256=Elfozf6MaqNLO4K7kImux4YDWlv_QMhT_E_w6BzRA2k,12070
|
|
136
|
+
calkit_python-0.41.6.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
137
|
+
calkit_python-0.41.6.dist-info/entry_points.txt,sha256=2iQBBzjTAOdk66CwS-f3ecXXW5DjUqkG1KBRj8mHI1I,133
|
|
138
|
+
calkit_python-0.41.6.dist-info/licenses/LICENSE,sha256=9ZamCaSUTZk9rcrnf-sWFKLOHr3ws-S_dgKMegW4nw8,1056
|
|
139
|
+
calkit_python-0.41.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|