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.
Files changed (63) hide show
  1. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/PKG-INFO +4 -3
  2. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/README.md +2 -2
  3. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/deser.py +6 -19
  4. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/file_transfer.py +9 -1
  5. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/pagination.py +9 -3
  6. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/routes.py +4 -4
  7. kleinkram-0.56.0.dev20251201085236/kleinkram/auth.py +210 -0
  8. kleinkram-0.56.0.dev20251201085236/kleinkram/cli/_run.py +233 -0
  9. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/app.py +13 -1
  10. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/models.py +3 -0
  11. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/printing.py +4 -2
  12. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram.egg-info/PKG-INFO +4 -3
  13. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram.egg-info/SOURCES.txt +2 -2
  14. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram.egg-info/requires.txt +1 -0
  15. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram.egg-info/top_level.txt +0 -1
  16. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/requirements.txt +1 -0
  17. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/setup.cfg +1 -1
  18. {kleinkram-0.53.0/testing → kleinkram-0.56.0.dev20251201085236/tests}/backend_fixtures.py +28 -3
  19. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/conftest.py +1 -1
  20. kleinkram-0.56.0.dev20251201085236/tests/generate_test_data.py +89 -0
  21. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_core.py +1 -1
  22. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_end_to_end.py +2 -2
  23. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_fixtures.py +2 -2
  24. kleinkram-0.53.0/kleinkram/auth.py +0 -97
  25. kleinkram-0.53.0/kleinkram/cli/_run.py +0 -112
  26. kleinkram-0.53.0/tests/__init__.py +0 -0
  27. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/__init__.py +0 -0
  28. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/__main__.py +0 -0
  29. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/_version.py +0 -0
  30. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/__init__.py +0 -0
  31. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/client.py +0 -0
  32. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/api/query.py +0 -0
  33. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/__init__.py +0 -0
  34. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_action.py +0 -0
  35. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_download.py +0 -0
  36. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_endpoint.py +0 -0
  37. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_file.py +0 -0
  38. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_file_validator.py +0 -0
  39. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_list.py +0 -0
  40. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_mission.py +0 -0
  41. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_project.py +0 -0
  42. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_upload.py +0 -0
  43. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/_verify.py +0 -0
  44. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/cli/error_handling.py +0 -0
  45. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/config.py +0 -0
  46. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/core.py +0 -0
  47. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/errors.py +0 -0
  48. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/main.py +0 -0
  49. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/py.typed +0 -0
  50. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/types.py +0 -0
  51. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/utils.py +0 -0
  52. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram/wrappers.py +0 -0
  53. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram.egg-info/dependency_links.txt +0 -0
  54. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/kleinkram.egg-info/entry_points.txt +0 -0
  55. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/pyproject.toml +0 -0
  56. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/setup.py +0 -0
  57. {kleinkram-0.53.0/testing → kleinkram-0.56.0.dev20251201085236/tests}/__init__.py +0 -0
  58. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_config.py +0 -0
  59. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_error_handling.py +0 -0
  60. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_printing.py +0 -0
  61. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_query.py +0 -0
  62. {kleinkram-0.53.0 → kleinkram-0.56.0.dev20251201085236}/tests/test_utils.py +0 -0
  63. {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.53.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/testing` directory.
122
- To see the exact files that are required, see `cli/testing/backend_fixtures.py`.
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/testing` directory.
96
- To see the exact files that are required, see `cli/testing/backend_fixtures.py`.
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(DOWNLOAD_URL, params={"uuid": str(id), "expires": True})
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
- params[EXACT_MATCH] = str(exact_match).lower() # pass string rather than bool
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
- resp.raise_for_status() # TODO: this is fine for now
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 = "/action/listActions"
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}/details", params={"uuid": run_id})
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, "/action/listTemplates")
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}/submit", json=submit_payload)
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
- login_flow(oAuthProvider=oAuthProvider, key=key, headless=headless)
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