kleinkram 0.53.0__tar.gz → 0.56.0.dev20251201085236__tar.gz
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.
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/PKG-INFO +4 -3
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/README.md +2 -2
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/deser.py +6 -19
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/file_transfer.py +9 -1
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/pagination.py +9 -3
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/routes.py +4 -4
- kleinkram-0.56.0.dev20251201085236/kleinkram/auth.py +210 -0
- kleinkram-0.56.0.dev20251201085236/kleinkram/cli/_run.py +233 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/app.py +13 -1
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/models.py +3 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/printing.py +4 -2
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram.egg-info/PKG-INFO +4 -3
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram.egg-info/SOURCES.txt +2 -2
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram.egg-info/requires.txt +1 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram.egg-info/top_level.txt +0 -1
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/requirements.txt +1 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/setup.cfg +1 -1
- {kleinkram-0.53.0/testing → kleinkram-0.56.0.dev20251201085236/tests}/backend_fixtures.py +28 -3
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/conftest.py +1 -1
- kleinkram-0.56.0.dev20251201085236/tests/generate_test_data.py +89 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_core.py +1 -1
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_end_to_end.py +2 -2
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_fixtures.py +2 -2
- kleinkram-0.53.0/kleinkram/auth.py +0 -97
- kleinkram-0.53.0/kleinkram/cli/_run.py +0 -112
- kleinkram-0.53.0/tests/__init__.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/__init__.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/__main__.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/_version.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/__init__.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/client.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/query.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/__init__.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_action.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_download.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_endpoint.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_file.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_file_validator.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_list.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_mission.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_project.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_upload.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_verify.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/error_handling.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/config.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/core.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/errors.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/main.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/py.typed +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/types.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/utils.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/wrappers.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram.egg-info/dependency_links.txt +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram.egg-info/entry_points.txt +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/pyproject.toml +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/setup.py +0 -0
- {kleinkram-0.53.0/testing → kleinkram-0.56.0.dev20251201085236/tests}/__init__.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_config.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_error_handling.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_printing.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_query.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_utils.py +0 -0
- {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_wrappers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kleinkram
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.56.0.dev20251201085236
|
|
4
4
|
Summary: give me your bags
|
|
5
5
|
Author: Cyrill Püntener, Dominique Garmier, Johann Schwabe
|
|
6
6
|
Author-email: pucyril@ethz.ch, dgarmier@ethz.ch, jschwab@ethz.ch
|
|
@@ -23,6 +23,7 @@ Requires-Dist: rich
|
|
|
23
23
|
Requires-Dist: tqdm
|
|
24
24
|
Requires-Dist: typer
|
|
25
25
|
Requires-Dist: click
|
|
26
|
+
Requires-Dist: requests
|
|
26
27
|
|
|
27
28
|
# Kleinkram: CLI
|
|
28
29
|
|
|
@@ -118,7 +119,7 @@ pytest
|
|
|
118
119
|
```
|
|
119
120
|
For the latter you need to have an instance of the backend running locally.
|
|
120
121
|
See instructions in the root of the repository for this.
|
|
121
|
-
On top of that these tests require particular files to be present in the `cli/data
|
|
122
|
-
|
|
122
|
+
On top of that these tests require particular files to be present in the `cli/tests/data` directory.
|
|
123
|
+
These files are automatically generated by the `cli/tests/generate_test_data.py` script.
|
|
123
124
|
|
|
124
125
|
You also need to make sure to be logged in with the cli with `klein login`.
|
|
@@ -92,7 +92,7 @@ pytest
|
|
|
92
92
|
```
|
|
93
93
|
For the latter you need to have an instance of the backend running locally.
|
|
94
94
|
See instructions in the root of the repository for this.
|
|
95
|
-
On top of that these tests require particular files to be present in the `cli/data
|
|
96
|
-
|
|
95
|
+
On top of that these tests require particular files to be present in the `cli/tests/data` directory.
|
|
96
|
+
These files are automatically generated by the `cli/tests/generate_test_data.py` script.
|
|
97
97
|
|
|
98
98
|
You also need to make sure to be logged in with the cli with `klein login`.
|
|
@@ -71,30 +71,13 @@ class ProjectObjectKeys(str, Enum):
|
|
|
71
71
|
class RunObjectKeys(str, Enum):
|
|
72
72
|
UUID = "uuid"
|
|
73
73
|
STATE = "state"
|
|
74
|
+
STATE_CAUSE = "stateCause"
|
|
74
75
|
CREATED_AT = "createdAt"
|
|
75
76
|
MISSION = "mission"
|
|
76
77
|
TEMPLATE = "template"
|
|
77
78
|
UPDATED_AT = "updatedAt"
|
|
78
79
|
LOGS = "logs"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"""
|
|
82
|
-
@dataclass(frozen=True)
|
|
83
|
-
class ActionTemplate:
|
|
84
|
-
uuid: UUID
|
|
85
|
-
access_rights: int
|
|
86
|
-
command: str
|
|
87
|
-
cpu_cores: int
|
|
88
|
-
cpu_memory_gb: int
|
|
89
|
-
entrypoint: str
|
|
90
|
-
gpu_memory_gb: int
|
|
91
|
-
image_name: str
|
|
92
|
-
max_runtime_minutes: int
|
|
93
|
-
created_at: datetime
|
|
94
|
-
name: str
|
|
95
|
-
version: str
|
|
96
|
-
|
|
97
|
-
"""
|
|
80
|
+
ARTIFACT_URL = "artifactUrl"
|
|
98
81
|
|
|
99
82
|
|
|
100
83
|
class TemplateObjectKeys(str, Enum):
|
|
@@ -303,6 +286,8 @@ def _parse_run(run_object: RunObject) -> Run:
|
|
|
303
286
|
try:
|
|
304
287
|
uuid_ = UUID(run_object[RunObjectKeys.UUID], version=4)
|
|
305
288
|
state = run_object[RunObjectKeys.STATE]
|
|
289
|
+
state_cause = run_object[RunObjectKeys.STATE_CAUSE]
|
|
290
|
+
artifact_url = run_object.get(RunObjectKeys.ARTIFACT_URL)
|
|
306
291
|
created_at = _parse_datetime(run_object[RunObjectKeys.CREATED_AT])
|
|
307
292
|
updated_at = (
|
|
308
293
|
_parse_datetime(run_object[RunObjectKeys.UPDATED_AT])
|
|
@@ -339,6 +324,8 @@ def _parse_run(run_object: RunObject) -> Run:
|
|
|
339
324
|
return Run(
|
|
340
325
|
uuid=uuid_,
|
|
341
326
|
state=state,
|
|
327
|
+
state_cause=state_cause,
|
|
328
|
+
artifact_url=artifact_url,
|
|
342
329
|
created_at=created_at,
|
|
343
330
|
updated_at=updated_at,
|
|
344
331
|
mission_id=mission_id,
|
|
@@ -62,6 +62,7 @@ def _confirm_file_upload(
|
|
|
62
62
|
data = {
|
|
63
63
|
"uuid": str(file_id),
|
|
64
64
|
"md5": file_hash,
|
|
65
|
+
"source": "CLI",
|
|
65
66
|
}
|
|
66
67
|
resp = client.post(UPLOAD_CONFIRM, json=data)
|
|
67
68
|
resp.raise_for_status()
|
|
@@ -96,6 +97,7 @@ def _get_upload_creditials(
|
|
|
96
97
|
dct = {
|
|
97
98
|
"filenames": [internal_filename],
|
|
98
99
|
"missionUUID": str(mission_id),
|
|
100
|
+
"source": "CLI",
|
|
99
101
|
}
|
|
100
102
|
resp = client.post(UPLOAD_CREDS, json=dct)
|
|
101
103
|
resp.raise_for_status()
|
|
@@ -251,7 +253,9 @@ def _get_file_download(client: AuthenticatedClient, id: UUID) -> str:
|
|
|
251
253
|
"""\
|
|
252
254
|
get the download url for a file by file id
|
|
253
255
|
"""
|
|
254
|
-
resp = client.get(
|
|
256
|
+
resp = client.get(
|
|
257
|
+
DOWNLOAD_URL, params={"uuid": str(id), "expires": True, "preview_only": False}
|
|
258
|
+
)
|
|
255
259
|
|
|
256
260
|
if 400 <= resp.status_code < 500:
|
|
257
261
|
raise AccessDenied(
|
|
@@ -406,6 +410,10 @@ def download_file(
|
|
|
406
410
|
|
|
407
411
|
observed_hash = b64_md5(path)
|
|
408
412
|
if file.hash is not None and observed_hash != file.hash:
|
|
413
|
+
print(
|
|
414
|
+
f"HASH MISMATCH: {path} expected={file.hash} observed={observed_hash}",
|
|
415
|
+
file=sys.stderr,
|
|
416
|
+
)
|
|
409
417
|
# Download completed but hash failed
|
|
410
418
|
return (
|
|
411
419
|
DownloadState.DOWNLOADED_INVALID_HASH,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from enum import Enum
|
|
4
3
|
from typing import Any
|
|
5
4
|
from typing import Dict
|
|
6
5
|
from typing import Generator
|
|
@@ -34,11 +33,18 @@ def paginated_request(
|
|
|
34
33
|
|
|
35
34
|
params[TAKE] = page_size
|
|
36
35
|
params[SKIP] = 0
|
|
37
|
-
|
|
36
|
+
if exact_match:
|
|
37
|
+
params[EXACT_MATCH] = str(exact_match).lower() # pass string rather than bool
|
|
38
38
|
|
|
39
39
|
while True:
|
|
40
40
|
resp = client.get(endpoint, params=params)
|
|
41
|
-
|
|
41
|
+
|
|
42
|
+
# explicitly handle 404 if json contains message
|
|
43
|
+
if resp.status_code == 404 and "message" in resp.json():
|
|
44
|
+
raise ValueError(resp.json()["message"])
|
|
45
|
+
|
|
46
|
+
# raise for other errors
|
|
47
|
+
resp.raise_for_status()
|
|
42
48
|
|
|
43
49
|
paged_data = resp.json()
|
|
44
50
|
data_page = cast(List[DataPage], paged_data["data"])
|
|
@@ -185,7 +185,7 @@ def get_projects(
|
|
|
185
185
|
yield from map(lambda p: _parse_project(ProjectObject(p)), response_stream)
|
|
186
186
|
|
|
187
187
|
|
|
188
|
-
LIST_ACTIONS_ENDPOINT = "/
|
|
188
|
+
LIST_ACTIONS_ENDPOINT = "/actions"
|
|
189
189
|
|
|
190
190
|
|
|
191
191
|
def get_runs(
|
|
@@ -201,7 +201,7 @@ def get_run(
|
|
|
201
201
|
client: AuthenticatedClient,
|
|
202
202
|
run_id: str,
|
|
203
203
|
) -> Run:
|
|
204
|
-
resp = client.get(f"{ACTION_ENDPOINT}/
|
|
204
|
+
resp = client.get(f"{ACTION_ENDPOINT}s/{run_id}")
|
|
205
205
|
if resp.status_code == 404:
|
|
206
206
|
raise kleinkram.errors.RunNotFound(f"Run not found: {run_id}")
|
|
207
207
|
resp.raise_for_status()
|
|
@@ -211,7 +211,7 @@ def get_run(
|
|
|
211
211
|
def get_action_templates(
|
|
212
212
|
client: AuthenticatedClient,
|
|
213
213
|
) -> Generator[ActionTemplate, None, None]:
|
|
214
|
-
response_stream = paginated_request(client, "/
|
|
214
|
+
response_stream = paginated_request(client, "/templates")
|
|
215
215
|
yield from map(lambda p: _parse_action_template(RunObject(p)), response_stream)
|
|
216
216
|
|
|
217
217
|
|
|
@@ -247,7 +247,7 @@ def submit_action(
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
typer.echo("Submitting action...")
|
|
250
|
-
resp = client.post(f"{ACTION_ENDPOINT}
|
|
250
|
+
resp = client.post(f"{ACTION_ENDPOINT}s", json=submit_payload)
|
|
251
251
|
resp.raise_for_status() # Raises on 4xx/5xx responses
|
|
252
252
|
|
|
253
253
|
response_data = resp.json()
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import urllib.parse
|
|
4
|
+
import webbrowser
|
|
5
|
+
from getpass import getpass
|
|
6
|
+
from http.server import BaseHTTPRequestHandler
|
|
7
|
+
from http.server import HTTPServer
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from kleinkram.config import CONFIG_PATH
|
|
11
|
+
from kleinkram.config import Credentials
|
|
12
|
+
from kleinkram.config import get_config
|
|
13
|
+
from kleinkram.config import save_config
|
|
14
|
+
|
|
15
|
+
CLI_CALLBACK_ENDPOINT = "/cli/callback"
|
|
16
|
+
OAUTH_SLUG = "/auth/"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _has_browser() -> bool:
|
|
20
|
+
try:
|
|
21
|
+
webbrowser.get()
|
|
22
|
+
return True
|
|
23
|
+
except webbrowser.Error:
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _headless_auth(*, url: str) -> None:
|
|
28
|
+
|
|
29
|
+
print(f"please open the following URL manually to authenticate: {url}")
|
|
30
|
+
print("enter the authentication token provided after logging in:")
|
|
31
|
+
auth_token = getpass("authentication token: ")
|
|
32
|
+
refresh_token = getpass("refresh token: ")
|
|
33
|
+
|
|
34
|
+
if auth_token and refresh_token:
|
|
35
|
+
config = get_config()
|
|
36
|
+
config.credentials = Credentials(
|
|
37
|
+
auth_token=auth_token, refresh_token=refresh_token
|
|
38
|
+
)
|
|
39
|
+
save_config(config)
|
|
40
|
+
print(f"Authentication complete. Tokens saved to {CONFIG_PATH}.")
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError("Please provided tokens.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
46
|
+
def do_GET(self):
|
|
47
|
+
if self.path.startswith(CLI_CALLBACK_ENDPOINT):
|
|
48
|
+
query = urllib.parse.urlparse(self.path).query
|
|
49
|
+
params = urllib.parse.parse_qs(query)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
creds = Credentials(
|
|
53
|
+
auth_token=params.get("authtoken")[0], # type: ignore
|
|
54
|
+
refresh_token=params.get("refreshtoken")[0], # type: ignore
|
|
55
|
+
)
|
|
56
|
+
config = get_config()
|
|
57
|
+
config.credentials = creds
|
|
58
|
+
save_config(config)
|
|
59
|
+
except Exception:
|
|
60
|
+
raise RuntimeError("Failed to fetch authentication tokens.")
|
|
61
|
+
|
|
62
|
+
self.send_response(200)
|
|
63
|
+
self.send_header("Content-type", "text/html")
|
|
64
|
+
self.end_headers()
|
|
65
|
+
self.wfile.write(b"Authentication successful. You can close this window.")
|
|
66
|
+
else:
|
|
67
|
+
raise RuntimeError("Invalid path")
|
|
68
|
+
|
|
69
|
+
def log_message(self, *args, **kwargs):
|
|
70
|
+
_ = args, kwargs
|
|
71
|
+
pass # suppress logging
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _browser_auth(*, url: str) -> None:
|
|
75
|
+
webbrowser.open(url)
|
|
76
|
+
|
|
77
|
+
server = HTTPServer(("", 8000), OAuthCallbackHandler)
|
|
78
|
+
server.handle_request()
|
|
79
|
+
|
|
80
|
+
print(f"Authentication complete. Tokens saved to {CONFIG_PATH}.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _direct_oauth_auth(*, endpoint: str, provider: str, user: str) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Directly authenticate with fake OAuth by programmatically following the OAuth flow.
|
|
86
|
+
This bypasses the browser entirely for automated testing.
|
|
87
|
+
"""
|
|
88
|
+
import requests
|
|
89
|
+
|
|
90
|
+
print(f"Authenticating as user {user} with {provider}...")
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
# Step 1: Get the authorization code from fake OAuth
|
|
94
|
+
# The fake OAuth server will auto-redirect when user parameter is provided
|
|
95
|
+
fake_oauth_url = f"http://localhost:8004/oauth/authorize"
|
|
96
|
+
callback_url = f"{endpoint}/auth/{provider}/callback"
|
|
97
|
+
|
|
98
|
+
params = {
|
|
99
|
+
"client_id": "some-random-string-it-does-not-matter",
|
|
100
|
+
"redirect_uri": callback_url,
|
|
101
|
+
"response_type": "code",
|
|
102
|
+
"state": "cli-direct",
|
|
103
|
+
"user": user,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Make request to fake OAuth - it will redirect with the auth code
|
|
107
|
+
response = requests.get(fake_oauth_url, params=params, allow_redirects=False)
|
|
108
|
+
|
|
109
|
+
if response.status_code not in [301, 302, 303, 307, 308]:
|
|
110
|
+
raise RuntimeError(
|
|
111
|
+
f"Expected redirect from OAuth provider, got {response.status_code}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Extract the redirect location
|
|
115
|
+
location = response.headers.get("Location")
|
|
116
|
+
if not location:
|
|
117
|
+
raise RuntimeError("No redirect location from OAuth provider")
|
|
118
|
+
|
|
119
|
+
# Parse the callback URL to extract the auth code
|
|
120
|
+
parsed = urllib.parse.urlparse(location)
|
|
121
|
+
query_params = urllib.parse.parse_qs(parsed.query)
|
|
122
|
+
|
|
123
|
+
if "code" not in query_params:
|
|
124
|
+
raise RuntimeError(f"No authorization code in redirect: {location}")
|
|
125
|
+
|
|
126
|
+
auth_code = query_params["code"][0]
|
|
127
|
+
state = query_params.get("state", [None])[0]
|
|
128
|
+
|
|
129
|
+
print(f"Received authorization code, exchanging for tokens...")
|
|
130
|
+
|
|
131
|
+
# Step 2: Exchange the code for tokens by calling the backend callback
|
|
132
|
+
# Use a session to preserve cookies
|
|
133
|
+
session = requests.Session()
|
|
134
|
+
callback_params = {"code": auth_code}
|
|
135
|
+
if state:
|
|
136
|
+
callback_params["state"] = state
|
|
137
|
+
|
|
138
|
+
callback_response = session.get(
|
|
139
|
+
callback_url, params=callback_params, allow_redirects=False
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# The backend should set cookies and redirect
|
|
143
|
+
if callback_response.status_code not in [301, 302, 303, 307, 308]:
|
|
144
|
+
raise RuntimeError(
|
|
145
|
+
f"Expected redirect from callback, got {callback_response.status_code}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Extract tokens from cookies
|
|
149
|
+
auth_token = session.cookies.get("authtoken")
|
|
150
|
+
refresh_token = session.cookies.get("refreshtoken")
|
|
151
|
+
|
|
152
|
+
if not auth_token or not refresh_token:
|
|
153
|
+
raise RuntimeError("Failed to get tokens from callback response")
|
|
154
|
+
|
|
155
|
+
# Save tokens
|
|
156
|
+
config = get_config()
|
|
157
|
+
config.credentials = Credentials(
|
|
158
|
+
auth_token=auth_token, refresh_token=refresh_token
|
|
159
|
+
)
|
|
160
|
+
save_config(config)
|
|
161
|
+
print(f"Authentication complete. Tokens saved to {CONFIG_PATH}.")
|
|
162
|
+
|
|
163
|
+
except requests.RequestException as e:
|
|
164
|
+
raise RuntimeError(f"OAuth flow failed: {e}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def login_flow(
|
|
168
|
+
*,
|
|
169
|
+
oAuthProvider: str,
|
|
170
|
+
key: Optional[str] = None,
|
|
171
|
+
headless: bool = False,
|
|
172
|
+
user: Optional[str] = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
config = get_config()
|
|
175
|
+
# use cli key login
|
|
176
|
+
if key is not None:
|
|
177
|
+
config.credentials = Credentials(api_key=key)
|
|
178
|
+
save_config(config)
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
# If user parameter is provided with fake-oauth, use direct OAuth flow
|
|
182
|
+
if user is not None and oAuthProvider == "fake-oauth":
|
|
183
|
+
_direct_oauth_auth(
|
|
184
|
+
endpoint=config.endpoint.api, provider=oAuthProvider, user=user
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
# Build OAuth URL with state parameter
|
|
189
|
+
oauth_url = f"{config.endpoint.api}{OAUTH_SLUG}{oAuthProvider}?state=cli"
|
|
190
|
+
|
|
191
|
+
# Add user parameter if provided (for fake-oauth auto-login)
|
|
192
|
+
if user is not None:
|
|
193
|
+
oauth_url += f"&user={user}"
|
|
194
|
+
|
|
195
|
+
is_port_available = True
|
|
196
|
+
try:
|
|
197
|
+
server = HTTPServer(("", 8000), OAuthCallbackHandler)
|
|
198
|
+
server.server_close()
|
|
199
|
+
except OSError:
|
|
200
|
+
is_port_available = False
|
|
201
|
+
|
|
202
|
+
if not is_port_available:
|
|
203
|
+
print(
|
|
204
|
+
"Warning: Port 8000 is not available. Falling back to headless authentication.\n\n"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if not headless and _has_browser() and is_port_available:
|
|
208
|
+
_browser_auth(url=oauth_url)
|
|
209
|
+
else:
|
|
210
|
+
_headless_auth(url=f"{oauth_url}-no-redirect")
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tarfile
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from typing import Optional, List
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
import kleinkram.api.routes
|
|
14
|
+
from kleinkram.api.client import AuthenticatedClient
|
|
15
|
+
from kleinkram.api.query import RunQuery
|
|
16
|
+
from kleinkram.config import get_shared_state
|
|
17
|
+
from kleinkram.models import Run, LogEntry
|
|
18
|
+
from kleinkram.printing import print_runs_table, print_run_info, print_run_logs
|
|
19
|
+
from kleinkram.utils import split_args
|
|
20
|
+
|
|
21
|
+
HELP = """\
|
|
22
|
+
Manage and inspect action runs.
|
|
23
|
+
|
|
24
|
+
You can list action runs, get detailed information about specific runs, stream their logs,
|
|
25
|
+
cancel runs in progress, and retry failed runs.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
run_typer = typer.Typer(
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
31
|
+
help=HELP,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
LIST_HELP = "List action runs. Optionally filter by mission or project."
|
|
35
|
+
INFO_HELP = "Get detailed information about a specific action run."
|
|
36
|
+
LOGS_HELP = "Stream the logs for a specific action run."
|
|
37
|
+
CANCEL_HELP = "Cancel an action run that is in progress."
|
|
38
|
+
RETRY_HELP = "Retry a failed action run."
|
|
39
|
+
DOWNLOAD_HELP = "Download artifacts for a specific action run."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@run_typer.command(help=LIST_HELP, name="list")
|
|
43
|
+
def list_runs(
|
|
44
|
+
mission: Optional[str] = typer.Option(
|
|
45
|
+
None, "--mission", "-m", help="Mission ID or name to filter by."
|
|
46
|
+
),
|
|
47
|
+
project: Optional[str] = typer.Option(
|
|
48
|
+
None, "--project", "-p", help="Project ID or name to filter by."
|
|
49
|
+
),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""
|
|
52
|
+
List action runs.
|
|
53
|
+
"""
|
|
54
|
+
client = AuthenticatedClient()
|
|
55
|
+
|
|
56
|
+
mission_ids, mission_patterns = split_args([mission] if mission else [])
|
|
57
|
+
project_ids, project_patterns = split_args([project] if project else [])
|
|
58
|
+
|
|
59
|
+
query = RunQuery(
|
|
60
|
+
mission_ids=mission_ids,
|
|
61
|
+
mission_patterns=mission_patterns,
|
|
62
|
+
project_ids=project_ids,
|
|
63
|
+
project_patterns=project_patterns,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
runs = list(kleinkram.api.routes.get_runs(client, query=query))
|
|
67
|
+
print_runs_table(runs, pprint=get_shared_state().verbose)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@run_typer.command(name="info", help=INFO_HELP)
|
|
71
|
+
def get_info(
|
|
72
|
+
run_id: str = typer.Argument(..., help="The ID of the run to get information for.")
|
|
73
|
+
) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Get detailed information for a single run.
|
|
76
|
+
"""
|
|
77
|
+
client = AuthenticatedClient()
|
|
78
|
+
run: Run = kleinkram.api.routes.get_run(client, run_id=run_id)
|
|
79
|
+
print_run_info(run, pprint=get_shared_state().verbose)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@run_typer.command(help=LOGS_HELP)
|
|
83
|
+
def logs(
|
|
84
|
+
run_id: str = typer.Argument(..., help="The ID of the run to fetch logs for."),
|
|
85
|
+
follow: bool = typer.Option(
|
|
86
|
+
False, "--follow", "-f", help="Follow the log output in real-time."
|
|
87
|
+
),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Fetch and display logs for a specific run.
|
|
91
|
+
"""
|
|
92
|
+
client = AuthenticatedClient()
|
|
93
|
+
|
|
94
|
+
if follow:
|
|
95
|
+
typer.echo(f"Watching logs for run {run_id}. Press Ctrl+C to stop.")
|
|
96
|
+
try:
|
|
97
|
+
|
|
98
|
+
# TODO: fine for now, but ideally we would have a streaming endpoint
|
|
99
|
+
# currently there is no following, thus we just poll every 2 seconds
|
|
100
|
+
# from the get_run endpoint
|
|
101
|
+
last_log_index = 0
|
|
102
|
+
while True:
|
|
103
|
+
run: Run = kleinkram.api.routes.get_run(client, run_id=run_id)
|
|
104
|
+
log_entries: List[LogEntry] = run.logs
|
|
105
|
+
new_log_entries = log_entries[last_log_index:]
|
|
106
|
+
if new_log_entries:
|
|
107
|
+
print_run_logs(new_log_entries, pprint=get_shared_state().verbose)
|
|
108
|
+
last_log_index += len(new_log_entries)
|
|
109
|
+
|
|
110
|
+
time.sleep(2)
|
|
111
|
+
|
|
112
|
+
except KeyboardInterrupt:
|
|
113
|
+
typer.echo("Stopped following logs.")
|
|
114
|
+
sys.exit(0)
|
|
115
|
+
else:
|
|
116
|
+
log_entries = kleinkram.api.routes.get_run(client, run_id=run_id).logs
|
|
117
|
+
print_run_logs(log_entries, pprint=get_shared_state().verbose)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _get_filename_from_cd(cd: str) -> Optional[str]:
|
|
121
|
+
"""Extract filename from Content-Disposition header."""
|
|
122
|
+
if not cd:
|
|
123
|
+
return None
|
|
124
|
+
fname = re.findall("filename=(.+)", cd)
|
|
125
|
+
if len(fname) == 0:
|
|
126
|
+
return None
|
|
127
|
+
return fname[0].strip().strip('"')
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@run_typer.command(name="download", help=DOWNLOAD_HELP)
|
|
131
|
+
def download_artifacts(
|
|
132
|
+
run_id: str = typer.Argument(
|
|
133
|
+
..., help="The ID of the run to download artifacts for."
|
|
134
|
+
),
|
|
135
|
+
output: Optional[str] = typer.Option(
|
|
136
|
+
None, "--output", "-o", help="Path or filename to save the artifacts to."
|
|
137
|
+
),
|
|
138
|
+
extract: bool = typer.Option(
|
|
139
|
+
False,
|
|
140
|
+
"--extract",
|
|
141
|
+
"-x",
|
|
142
|
+
help="Automatically extract the archive after downloading.",
|
|
143
|
+
),
|
|
144
|
+
) -> None:
|
|
145
|
+
"""
|
|
146
|
+
Download the artifacts (.tar.gz) for a finished run.
|
|
147
|
+
"""
|
|
148
|
+
client = AuthenticatedClient()
|
|
149
|
+
|
|
150
|
+
# Fetch Run Details
|
|
151
|
+
try:
|
|
152
|
+
run: Run = kleinkram.api.routes.get_run(client, run_id=run_id)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
typer.secho(f"Failed to fetch run details: {e}", fg=typer.colors.RED)
|
|
155
|
+
raise typer.Exit(1)
|
|
156
|
+
|
|
157
|
+
if not run.artifact_url:
|
|
158
|
+
typer.secho(
|
|
159
|
+
f"No artifacts found for run {run_id}. The run might not be finished or artifacts expired.",
|
|
160
|
+
fg=typer.colors.YELLOW,
|
|
161
|
+
)
|
|
162
|
+
raise typer.Exit(1)
|
|
163
|
+
|
|
164
|
+
typer.echo(f"Downloading artifacts for run {run_id}...")
|
|
165
|
+
|
|
166
|
+
# Stream Download
|
|
167
|
+
try:
|
|
168
|
+
with requests.get(run.artifact_url, stream=True) as r:
|
|
169
|
+
r.raise_for_status()
|
|
170
|
+
|
|
171
|
+
# Determine Filename
|
|
172
|
+
filename = output
|
|
173
|
+
if not filename:
|
|
174
|
+
filename = _get_filename_from_cd(r.headers.get("content-disposition"))
|
|
175
|
+
|
|
176
|
+
if not filename:
|
|
177
|
+
filename = f"{run_id}.tar.gz"
|
|
178
|
+
|
|
179
|
+
# If output is a directory, join with filename
|
|
180
|
+
if output and os.path.isdir(output):
|
|
181
|
+
filename = os.path.join(
|
|
182
|
+
output,
|
|
183
|
+
_get_filename_from_cd(r.headers.get("content-disposition"))
|
|
184
|
+
or f"{run_id}.tar.gz",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
total_length = int(r.headers.get("content-length", 0))
|
|
188
|
+
|
|
189
|
+
# Write to file with Progress Bar
|
|
190
|
+
with open(filename, "wb") as f:
|
|
191
|
+
with typer.progressbar(
|
|
192
|
+
length=total_length, label=f"Saving to {filename}"
|
|
193
|
+
) as progress:
|
|
194
|
+
for chunk in r.iter_content(chunk_size=8192):
|
|
195
|
+
if chunk:
|
|
196
|
+
f.write(chunk)
|
|
197
|
+
progress.update(len(chunk))
|
|
198
|
+
|
|
199
|
+
typer.secho(
|
|
200
|
+
f"\nSuccessfully downloaded to {filename}", fg=typer.colors.GREEN
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Extraction Logic
|
|
204
|
+
if extract:
|
|
205
|
+
try:
|
|
206
|
+
# Determine extraction directory (based on filename without extension)
|
|
207
|
+
# e.g., "downloads/my-run.tar" -> "downloads/my-run"
|
|
208
|
+
base_name = os.path.basename(filename)
|
|
209
|
+
folder_name = base_name.split(".")[0]
|
|
210
|
+
|
|
211
|
+
# Get the parent directory of the downloaded file
|
|
212
|
+
parent_dir = os.path.dirname(os.path.abspath(filename))
|
|
213
|
+
extract_path = os.path.join(parent_dir, folder_name)
|
|
214
|
+
|
|
215
|
+
typer.echo(f"Extracting to: {extract_path}...")
|
|
216
|
+
|
|
217
|
+
with tarfile.open(filename, "r:gz") as tar:
|
|
218
|
+
|
|
219
|
+
# Safety check: filter_data prevents extraction outside target dir (CVE-2007-4559)
|
|
220
|
+
# Available in Python 3.12+, for older python use generic extractall
|
|
221
|
+
if hasattr(tarfile, "data_filter"):
|
|
222
|
+
tar.extractall(path=extract_path, filter="data")
|
|
223
|
+
else:
|
|
224
|
+
tar.extractall(path=extract_path)
|
|
225
|
+
|
|
226
|
+
typer.secho(f"Successfully extracted.", fg=typer.colors.GREEN)
|
|
227
|
+
|
|
228
|
+
except tarfile.TarError as e:
|
|
229
|
+
typer.secho(f"Failed to extract archive: {e}", fg=typer.colors.RED)
|
|
230
|
+
|
|
231
|
+
except requests.exceptions.RequestException as e:
|
|
232
|
+
typer.secho(f"Error downloading file: {e}", fg=typer.colors.RED)
|
|
233
|
+
raise typer.Exit(1)
|
|
@@ -141,6 +141,12 @@ def login(
|
|
|
141
141
|
),
|
|
142
142
|
key: Optional[str] = typer.Option(None, help="CLI key"),
|
|
143
143
|
headless: bool = typer.Option(False),
|
|
144
|
+
user: Optional[str] = typer.Option(
|
|
145
|
+
None,
|
|
146
|
+
"--user",
|
|
147
|
+
"-u",
|
|
148
|
+
help="Auto-select user ID for fake-oauth (e.g., 1, 2, 3). Only works with fake-oauth provider.",
|
|
149
|
+
),
|
|
144
150
|
) -> None:
|
|
145
151
|
|
|
146
152
|
# logic to resolve the "auto" default
|
|
@@ -157,7 +163,13 @@ def login(
|
|
|
157
163
|
f"Unsupported OAuth provider '{oAuthProvider}'. Supported providers: google, github, fake-oauth."
|
|
158
164
|
)
|
|
159
165
|
|
|
160
|
-
|
|
166
|
+
# validate that user parameter is only used with fake-oauth
|
|
167
|
+
if user is not None and oAuthProvider != "fake-oauth":
|
|
168
|
+
raise typer.BadParameter(
|
|
169
|
+
"--user parameter can only be used with fake-oauth provider"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
login_flow(oAuthProvider=oAuthProvider, key=key, headless=headless, user=user)
|
|
161
173
|
|
|
162
174
|
|
|
163
175
|
@app.command(rich_help_panel=CommandTypes.AUTH)
|
|
@@ -29,6 +29,7 @@ class FileState(str, Enum):
|
|
|
29
29
|
CORRUPTED = "CORRUPTED"
|
|
30
30
|
UPLOADING = "UPLOADING"
|
|
31
31
|
ERROR = "ERROR"
|
|
32
|
+
CONVERTING = "CONVERTING"
|
|
32
33
|
CONVERSION_ERROR = "CONVERSION_ERROR"
|
|
33
34
|
LOST = "LOST"
|
|
34
35
|
FOUND = "FOUND"
|
|
@@ -95,6 +96,8 @@ class LogEntry:
|
|
|
95
96
|
class Run:
|
|
96
97
|
uuid: UUID
|
|
97
98
|
state: str
|
|
99
|
+
state_cause: str | None
|
|
100
|
+
artifact_url: str | None
|
|
98
101
|
created_at: datetime
|
|
99
102
|
updated_at: datetime | None
|
|
100
103
|
project_name: str
|