kleinkram 0.43.2.dev20250331124109__py3-none-any.whl → 0.58.0.dev20260110152317__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. kleinkram/api/client.py +6 -18
  2. kleinkram/api/deser.py +152 -1
  3. kleinkram/api/file_transfer.py +202 -101
  4. kleinkram/api/pagination.py +11 -2
  5. kleinkram/api/query.py +10 -10
  6. kleinkram/api/routes.py +192 -59
  7. kleinkram/auth.py +108 -7
  8. kleinkram/cli/_action.py +131 -0
  9. kleinkram/cli/_download.py +8 -19
  10. kleinkram/cli/_endpoint.py +2 -4
  11. kleinkram/cli/_file.py +6 -18
  12. kleinkram/cli/_file_validator.py +125 -0
  13. kleinkram/cli/_list.py +5 -15
  14. kleinkram/cli/_mission.py +24 -28
  15. kleinkram/cli/_project.py +10 -26
  16. kleinkram/cli/_run.py +220 -0
  17. kleinkram/cli/_upload.py +58 -26
  18. kleinkram/cli/_verify.py +59 -16
  19. kleinkram/cli/app.py +56 -17
  20. kleinkram/cli/error_handling.py +1 -3
  21. kleinkram/config.py +6 -21
  22. kleinkram/core.py +53 -43
  23. kleinkram/errors.py +12 -0
  24. kleinkram/models.py +51 -1
  25. kleinkram/printing.py +229 -18
  26. kleinkram/utils.py +10 -24
  27. kleinkram/wrappers.py +54 -30
  28. {kleinkram-0.43.2.dev20250331124109.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/METADATA +6 -4
  29. kleinkram-0.58.0.dev20260110152317.dist-info/RECORD +53 -0
  30. {kleinkram-0.43.2.dev20250331124109.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/WHEEL +1 -1
  31. {kleinkram-0.43.2.dev20250331124109.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/top_level.txt +0 -1
  32. {testing → tests}/backend_fixtures.py +27 -3
  33. tests/conftest.py +1 -1
  34. tests/generate_test_data.py +314 -0
  35. tests/test_config.py +2 -6
  36. tests/test_core.py +11 -31
  37. tests/test_end_to_end.py +3 -5
  38. tests/test_fixtures.py +3 -5
  39. tests/test_printing.py +9 -11
  40. tests/test_utils.py +1 -3
  41. tests/test_wrappers.py +9 -27
  42. kleinkram-0.43.2.dev20250331124109.dist-info/RECORD +0 -50
  43. testing/__init__.py +0 -0
  44. {kleinkram-0.43.2.dev20250331124109.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/entry_points.txt +0 -0
kleinkram/core.py CHANGED
@@ -22,6 +22,7 @@ from typing import Optional
22
22
  from typing import Sequence
23
23
  from uuid import UUID
24
24
 
25
+ import httpx
25
26
  from rich.console import Console
26
27
  from tqdm import tqdm
27
28
 
@@ -33,6 +34,7 @@ from kleinkram.api.query import FileQuery
33
34
  from kleinkram.api.query import MissionQuery
34
35
  from kleinkram.api.query import ProjectQuery
35
36
  from kleinkram.api.query import check_mission_query_is_creatable
37
+ from kleinkram.errors import InvalidFileQuery
36
38
  from kleinkram.errors import MissionNotFound
37
39
  from kleinkram.models import FileState
38
40
  from kleinkram.models import FileVerificationStatus
@@ -67,16 +69,17 @@ def download(
67
69
  raise ValueError(f"Destination {base_dir.absolute()} is not a directory")
68
70
 
69
71
  # retrive files and get the destination paths
70
- files = list(kleinkram.api.routes.get_files(client, file_query=query))
72
+ try:
73
+ files = list(kleinkram.api.routes.get_files(client, file_query=query))
74
+ except httpx.HTTPStatusError:
75
+ raise InvalidFileQuery(f"Files not found. Maybe you forgot to specify mission or project flags: {query}")
71
76
  paths = file_paths_from_files(files, dest=base_dir, allow_nested=nested)
72
77
 
73
78
  if verbose:
74
79
  table = files_to_table(files, title="downloading files...")
75
80
  Console().print(table)
76
81
 
77
- kleinkram.api.file_transfer.download_files(
78
- client, paths, verbose=verbose, overwrite=overwrite
79
- )
82
+ kleinkram.api.file_transfer.download_files(client, paths, verbose=verbose, overwrite=overwrite)
80
83
 
81
84
 
82
85
  def upload(
@@ -107,9 +110,9 @@ def upload(
107
110
 
108
111
  if create and mission is None:
109
112
  # check if project exists and get its id at the same time
110
- project_id = kleinkram.api.routes.get_project(
111
- client, query=query.project_query
112
- ).id
113
+ project = kleinkram.api.routes.get_project(client, query=query.project_query, exact_match=True)
114
+ project_id = project.id
115
+ project_required_tags = project.required_tags
113
116
  mission_name = check_mission_query_is_creatable(query)
114
117
  kleinkram.api.routes._create_mission(
115
118
  client,
@@ -117,15 +120,14 @@ def upload(
117
120
  mission_name,
118
121
  metadata=metadata or {},
119
122
  ignore_missing_tags=ignore_missing_metadata,
123
+ required_tags=project_required_tags,
120
124
  )
121
125
  mission = kleinkram.api.routes.get_mission(client, query)
122
126
 
123
127
  assert mission is not None, "unreachable"
124
128
 
125
129
  filename_map = get_filename_map(file_paths)
126
- kleinkram.api.file_transfer.upload_files(
127
- client, filename_map, mission.id, verbose=verbose
128
- )
130
+ kleinkram.api.file_transfer.upload_files(client, filename_map, mission.id, verbose=verbose)
129
131
 
130
132
 
131
133
  def verify(
@@ -133,21 +135,27 @@ def verify(
133
135
  client: AuthenticatedClient,
134
136
  query: MissionQuery,
135
137
  file_paths: Sequence[Path],
136
- skip_hash: bool = False,
138
+ skip_hash: Optional[bool] = None,
139
+ check_file_hash: bool = True,
140
+ check_file_size: bool = False,
137
141
  verbose: bool = False,
138
142
  ) -> Dict[Path, FileVerificationStatus]:
143
+
144
+ # add deprecated warning for skip_hash
145
+ if skip_hash is not None:
146
+ print(
147
+ "Warning: --skip-hash is deprecated and will be removed in a future version. "
148
+ "Use --check-file-hash=False instead.",
149
+ )
150
+ check_file_hash = not skip_hash
151
+
139
152
  # check that file paths are for valid files and have valid suffixes
140
153
  check_file_paths(file_paths)
141
154
 
142
155
  # check that the mission exists
143
156
  _ = kleinkram.api.routes.get_mission(client, query)
144
157
 
145
- remote_files = {
146
- f.name: f
147
- for f in kleinkram.api.routes.get_files(
148
- client, file_query=FileQuery(mission_query=query)
149
- )
150
- }
158
+ remote_files = {f.name: f for f in kleinkram.api.routes.get_files(client, file_query=FileQuery(mission_query=query))}
151
159
  filename_map = get_filename_map(file_paths)
152
160
 
153
161
  # verify files
@@ -167,14 +175,30 @@ def verify(
167
175
  if remote_file.state == FileState.UPLOADING:
168
176
  file_status[file] = FileVerificationStatus.UPLOADING
169
177
  elif remote_file.state == FileState.OK:
170
- if remote_file.hash is None:
171
- file_status[file] = FileVerificationStatus.COMPUTING_HASH
172
- elif skip_hash or remote_file.hash == b64_md5(file):
173
- file_status[file] = FileVerificationStatus.UPLAODED
174
- else:
175
- file_status[file] = FileVerificationStatus.MISMATCHED_HASH
178
+
179
+ # default case, will be overwritten if we find a mismatch
180
+ file_status[file] = FileVerificationStatus.UPLOADED
181
+
182
+ if check_file_size:
183
+ if remote_file.size == file.stat().st_size:
184
+ file_status[file] = FileVerificationStatus.UPLOADED
185
+ else:
186
+ file_status[file] = FileVerificationStatus.MISMATCHED_SIZE
187
+
188
+ if file_status[file] != FileVerificationStatus.UPLOADED:
189
+ continue # abort if we already found a mismatch
190
+
191
+ if check_file_hash:
192
+ if remote_file.hash is None:
193
+ file_status[file] = FileVerificationStatus.COMPUTING_HASH
194
+ elif remote_file.hash == b64_md5(file):
195
+ file_status[file] = FileVerificationStatus.UPLOADED
196
+ else:
197
+ file_status[file] = FileVerificationStatus.MISMATCHED_HASH
198
+
176
199
  else:
177
200
  file_status[file] = FileVerificationStatus.UNKNOWN
201
+
178
202
  return file_status
179
203
 
180
204
 
@@ -186,9 +210,7 @@ def update_file(*, client: AuthenticatedClient, file_id: UUID) -> None:
186
210
  raise NotImplementedError("if you have an idea what this should do, open an issue")
187
211
 
188
212
 
189
- def update_mission(
190
- *, client: AuthenticatedClient, mission_id: UUID, metadata: Dict[str, str]
191
- ) -> None:
213
+ def update_mission(*, client: AuthenticatedClient, mission_id: UUID, metadata: Dict[str, str]) -> None:
192
214
  # TODO: this funciton will do more than just overwirte the metadata in the future
193
215
  kleinkram.api.routes._update_mission(client, mission_id, metadata=metadata)
194
216
 
@@ -201,9 +223,7 @@ def update_project(
201
223
  new_name: Optional[str] = None,
202
224
  ) -> None:
203
225
  # TODO: this function should do more in the future
204
- kleinkram.api.routes._update_project(
205
- client, project_id, description=description, new_name=new_name
206
- )
226
+ kleinkram.api.routes._update_project(client, project_id, description=description, new_name=new_name)
207
227
 
208
228
 
209
229
  def delete_files(*, client: AuthenticatedClient, file_ids: Collection[UUID]) -> None:
@@ -220,9 +240,7 @@ def delete_files(*, client: AuthenticatedClient, file_ids: Collection[UUID]) ->
220
240
  found_ids = [f.id for f in files]
221
241
  for file_id in file_ids:
222
242
  if file_id not in found_ids:
223
- raise kleinkram.errors.FileNotFound(
224
- f"file {file_id} not found, did not delete any files"
225
- )
243
+ raise kleinkram.errors.FileNotFound(f"file {file_id} not found, did not delete any files")
226
244
 
227
245
  # to prevent catastrophic mistakes from happening *again*
228
246
  assert set(file_ids) == set([file.id for file in files]), "unreachable"
@@ -241,11 +259,7 @@ def delete_files(*, client: AuthenticatedClient, file_ids: Collection[UUID]) ->
241
259
  def delete_mission(*, client: AuthenticatedClient, mission_id: UUID) -> None:
242
260
  mquery = MissionQuery(ids=[mission_id])
243
261
  mission = kleinkram.api.routes.get_mission(client, mquery)
244
- files = list(
245
- kleinkram.api.routes.get_files(
246
- client, file_query=FileQuery(mission_query=mquery)
247
- )
248
- )
262
+ files = list(kleinkram.api.routes.get_files(client, file_query=FileQuery(mission_query=mquery)))
249
263
 
250
264
  # delete the files and then the mission
251
265
  kleinkram.api.routes._delete_files(client, [f.id for f in files], mission.id)
@@ -254,14 +268,10 @@ def delete_mission(*, client: AuthenticatedClient, mission_id: UUID) -> None:
254
268
 
255
269
  def delete_project(*, client: AuthenticatedClient, project_id: UUID) -> None:
256
270
  pquery = ProjectQuery(ids=[project_id])
257
- _ = kleinkram.api.routes.get_project(client, pquery) # check if project exists
271
+ _ = kleinkram.api.routes.get_project(client, pquery, exact_match=True) # check if project exists
258
272
 
259
273
  # delete all missions and files
260
- missions = list(
261
- kleinkram.api.routes.get_missions(
262
- client, mission_query=MissionQuery(project_query=pquery)
263
- )
264
- )
274
+ missions = list(kleinkram.api.routes.get_missions(client, mission_query=MissionQuery(project_query=pquery)))
265
275
  for mission in missions:
266
276
  delete_mission(client=client, mission_id=mission.id)
267
277
 
kleinkram/errors.py CHANGED
@@ -43,9 +43,18 @@ class FileTypeNotSupported(Exception): ...
43
43
  class FileNameNotSupported(Exception): ...
44
44
 
45
45
 
46
+ class DatatypeNotSupported(Exception): ...
47
+
48
+
46
49
  class InvalidMissionMetadata(Exception): ...
47
50
 
48
51
 
52
+ class MissionValidationError(Exception): ...
53
+
54
+
55
+ class ProjectValidationError(Exception): ...
56
+
57
+
49
58
  class NotAuthenticated(Exception):
50
59
  def __init__(self) -> None:
51
60
  super().__init__(LOGIN_MESSAGE)
@@ -54,3 +63,6 @@ class NotAuthenticated(Exception):
54
63
  class UpdateCLIVersion(Exception):
55
64
  def __init__(self) -> None:
56
65
  super().__init__(UPDATE_MESSAGE)
66
+
67
+
68
+ class RunNotFound(Exception): ...
kleinkram/models.py CHANGED
@@ -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"
@@ -41,6 +42,7 @@ class Project:
41
42
  description: str
42
43
  created_at: datetime
43
44
  updated_at: datetime
45
+ required_tags: List[str]
44
46
 
45
47
 
46
48
  @dataclass(frozen=True)
@@ -75,11 +77,59 @@ class File:
75
77
  state: FileState = FileState.OK
76
78
 
77
79
 
80
+ class RunStatus(str, Enum):
81
+ QUEUED = "Queued"
82
+ IN_PROGRESS = "In Progress"
83
+ SUCCESS = "Success"
84
+ FAILED = "Failed"
85
+ CANCELLED = "Cancelled"
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class LogEntry:
90
+ timestamp: datetime
91
+ level: str
92
+ message: str
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Run:
97
+ uuid: UUID
98
+ state: str
99
+ state_cause: str | None
100
+ artifact_url: str | None
101
+ created_at: datetime
102
+ updated_at: datetime | None
103
+ project_name: str
104
+ mission_id: UUID
105
+ mission_name: str
106
+ template_id: UUID
107
+ template_name: str
108
+ logs: List[LogEntry] = field(default_factory=list)
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class ActionTemplate:
113
+ uuid: UUID
114
+ access_rights: int
115
+ command: str
116
+ cpu_cores: int
117
+ cpu_memory_gb: int
118
+ entrypoint: str
119
+ gpu_memory_gb: int
120
+ image_name: str
121
+ max_runtime_minutes: int
122
+ created_at: datetime
123
+ name: str
124
+ version: str
125
+
126
+
78
127
  # this is the file state for the verify command
79
128
  class FileVerificationStatus(str, Enum):
80
- UPLAODED = "uploaded"
129
+ UPLOADED = "uploaded"
81
130
  UPLOADING = "uploading"
82
131
  COMPUTING_HASH = "computing hash"
83
132
  MISSING = "missing"
84
133
  MISMATCHED_HASH = "hash mismatch"
134
+ MISMATCHED_SIZE = "size mismatch"
85
135
  UNKNOWN = "unknown"
kleinkram/printing.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import sys
5
+ import time
5
6
  from dataclasses import asdict
6
7
  from datetime import datetime
7
8
  from pathlib import Path
@@ -13,24 +14,32 @@ from typing import Tuple
13
14
  from typing import Union
14
15
 
15
16
  import dateutil.parser
17
+ import httpx
18
+ import typer
16
19
  from rich.console import Console
17
20
  from rich.table import Table
18
21
  from rich.text import Text
19
22
 
23
+ import kleinkram
24
+ from kleinkram.api.client import AuthenticatedClient
20
25
  from kleinkram.config import get_shared_state
21
26
  from kleinkram.core import FileVerificationStatus
27
+ from kleinkram.models import ActionTemplate
22
28
  from kleinkram.models import File
23
29
  from kleinkram.models import FileState
30
+ from kleinkram.models import LogEntry
24
31
  from kleinkram.models import MetadataValue
25
32
  from kleinkram.models import MetadataValueType
26
33
  from kleinkram.models import Mission
27
34
  from kleinkram.models import Project
35
+ from kleinkram.models import Run
28
36
 
29
37
  FILE_STATE_COLOR = {
30
38
  FileState.OK: "green",
31
39
  FileState.CORRUPTED: "red",
32
40
  FileState.UPLOADING: "yellow",
33
41
  FileState.ERROR: "red",
42
+ FileState.CONVERTING: "blue",
34
43
  FileState.CONVERSION_ERROR: "red",
35
44
  FileState.LOST: "bold red",
36
45
  FileState.FOUND: "yellow",
@@ -38,10 +47,11 @@ FILE_STATE_COLOR = {
38
47
 
39
48
 
40
49
  FILE_VERIFICATION_STATUS_STYLES = {
41
- FileVerificationStatus.UPLAODED: "green",
50
+ FileVerificationStatus.UPLOADED: "green",
42
51
  FileVerificationStatus.UPLOADING: "yellow",
43
52
  FileVerificationStatus.MISSING: "yellow",
44
53
  FileVerificationStatus.MISMATCHED_HASH: "red",
54
+ FileVerificationStatus.MISMATCHED_SIZE: "red",
45
55
  FileVerificationStatus.UNKNOWN: "gray",
46
56
  FileVerificationStatus.COMPUTING_HASH: "purple",
47
57
  }
@@ -84,8 +94,8 @@ def format_bytes(size: int) -> str:
84
94
  index = 0
85
95
 
86
96
  fsize: float = size
87
- while fsize >= 1024 and index < len(units) - 1:
88
- fsize /= 1024.0
97
+ while fsize >= 1000 and index < len(units) - 1:
98
+ fsize /= 1000.0
89
99
  index += 1
90
100
 
91
101
  # Format to 2 decimal places if needed
@@ -165,9 +175,7 @@ def missions_to_table(missions: Sequence[Mission]) -> Table:
165
175
  return table
166
176
 
167
177
 
168
- def files_to_table(
169
- files: Sequence[File], *, title: str = "files", delimiters: bool = True
170
- ) -> Table:
178
+ def files_to_table(files: Sequence[File], *, title: str = "files", delimiters: bool = True) -> Table:
171
179
  table = Table(title=title)
172
180
  table.add_column("project")
173
181
  table.add_column("mission")
@@ -231,9 +239,7 @@ def file_info_table(file: File) -> Table:
231
239
  return table
232
240
 
233
241
 
234
- def mission_info_table(
235
- mission: Mission, print_metadata: bool = False
236
- ) -> Tuple[Table, ...]:
242
+ def mission_info_table(mission: Mission, print_metadata: bool = True) -> Tuple[Table, ...]:
237
243
  table = Table("k", "v", title=f"mission info: {mission.name}", show_header=False)
238
244
 
239
245
  # TODO: add more fields as we store more information in the Mission object
@@ -250,9 +256,7 @@ def mission_info_table(
250
256
  return (table,)
251
257
 
252
258
  metadata_table = Table("k", "v", title="mission metadata", show_header=False)
253
- kv_pairs_sorted = sorted(
254
- [(k, v) for k, v in mission.metadata.items()], key=lambda x: x[0]
255
- )
259
+ kv_pairs_sorted = sorted([(k, v) for k, v in mission.metadata.items()], key=lambda x: x[0])
256
260
  for k, v in kv_pairs_sorted:
257
261
  metadata_table.add_row(k, str(parse_metadata_value(v)))
258
262
 
@@ -268,6 +272,7 @@ def project_info_table(project: Project) -> Table:
268
272
  table.add_row("description", project.description)
269
273
  table.add_row("created", str(project.created_at))
270
274
  table.add_row("updated", str(project.updated_at))
275
+ table.add_row("required tags", ", ".join(project.required_tags))
271
276
 
272
277
  return table
273
278
 
@@ -283,9 +288,7 @@ def file_verification_status_table(
283
288
  return table
284
289
 
285
290
 
286
- def print_file_verification_status(
287
- file_status: Mapping[Path, FileVerificationStatus], *, pprint: bool
288
- ) -> None:
291
+ def print_file_verification_status(file_status: Mapping[Path, FileVerificationStatus], *, pprint: bool) -> None:
289
292
  """\
290
293
  prints the file verification status to stdout / stderr
291
294
  either using pprint or as a list for piping
@@ -295,9 +298,7 @@ def print_file_verification_status(
295
298
  Console().print(table)
296
299
  else:
297
300
  for path, status in file_status.items():
298
- stream = (
299
- sys.stdout if status == FileVerificationStatus.UPLAODED else sys.stderr
300
- )
301
+ stream = sys.stdout if status == FileVerificationStatus.UPLOADED else sys.stderr
301
302
  print(path, file=stream, flush=True)
302
303
 
303
304
 
@@ -382,3 +383,213 @@ def print_project_info(project: Project, *, pprint: bool) -> None:
382
383
  for key in project_dct:
383
384
  project_dct[key] = str(project_dct[key]) # TODO: improve this
384
385
  print(json.dumps(project_dct))
386
+
387
+
388
+ def runs_to_table(runs: Sequence[Run]) -> Table:
389
+ table = Table(title="action runs")
390
+ table.add_column("project")
391
+ table.add_column("mission")
392
+ table.add_column("template")
393
+ table.add_column("run id")
394
+ table.add_column("status")
395
+ table.add_column("created")
396
+
397
+ # order by created_at descending
398
+ runs_sorted = sorted(runs, key=lambda r: r.created_at, reverse=True)
399
+
400
+ max_table_size = get_shared_state().max_table_size
401
+ for run in runs_sorted[:max_table_size]:
402
+ table.add_row(
403
+ run.project_name,
404
+ run.mission_name,
405
+ run.template_name,
406
+ Text(str(run.uuid), style="green"),
407
+ run.state,
408
+ str(run.created_at),
409
+ )
410
+
411
+ if len(list(runs)) > max_table_size:
412
+ _add_placeholder_row(table, skipped=len(runs) - max_table_size)
413
+ return table
414
+
415
+
416
+ def run_info_table(run: Run) -> Table:
417
+ table = Table("k", "v", title=f"run info: {run.uuid}", show_header=False)
418
+
419
+ table.add_row("id", Text(str(run.uuid), style="green"))
420
+ table.add_row("template", run.template_name)
421
+ table.add_row("status", run.state)
422
+ table.add_row("project", run.project_name)
423
+ table.add_row("mission", run.mission_name)
424
+ table.add_row("created", str(run.created_at))
425
+
426
+ finished = str(run.updated_at) if run.updated_at else "N/A"
427
+ table.add_row("updated", finished)
428
+
429
+ return table
430
+
431
+
432
+ def print_runs_table(runs: Sequence[Run], *, pprint: bool) -> None:
433
+ """
434
+ Prints the runs to stdout
435
+ either using pprint or as a list for piping
436
+ """
437
+ if pprint:
438
+ table = runs_to_table(runs)
439
+ Console().print(table)
440
+ else:
441
+ for run in runs:
442
+ print(run.uuid)
443
+
444
+
445
+ def print_run_info(run: Run, *, pprint: bool) -> None:
446
+ """
447
+ Prints the run info to stdout
448
+ either using pprint or as JSON for piping
449
+ """
450
+ if pprint:
451
+ Console().print(run_info_table(run))
452
+ else:
453
+ run_dict = asdict(run)
454
+ for key in run_dict:
455
+ run_dict[key] = str(run_dict[key]) # simple serialization
456
+ print(json.dumps(run_dict))
457
+
458
+
459
+ LOG_LEVEL_COLORS = {
460
+ "DEBUG": typer.colors.CYAN,
461
+ "INFO": typer.colors.GREEN,
462
+ "WARNING": typer.colors.YELLOW,
463
+ "ERROR": typer.colors.RED,
464
+ "CRITICAL": typer.colors.BRIGHT_RED,
465
+ "STDOUT": typer.colors.BRIGHT_BLACK,
466
+ "STDERR": typer.colors.RED,
467
+ }
468
+
469
+
470
+ def pretty_print_log(entry: LogEntry) -> None:
471
+ """
472
+ Prints a single LogEntry object to the console with
473
+ colors and standardized formatting.
474
+
475
+ This version correctly handles carriage returns (from tqdm)
476
+ and empty lines.
477
+ """
478
+ # Clean up the level name, just in case
479
+ level = entry.level.upper().strip()
480
+ color = LOG_LEVEL_COLORS.get(level, typer.colors.WHITE)
481
+ timestamp_str = entry.timestamp.strftime("%Y-%m-%d %H:%M:%S")
482
+ level_str = f"[{level.ljust(8)}]"
483
+ message = entry.message.strip()
484
+
485
+ if not message:
486
+ return
487
+
488
+ typer.secho(f"[{timestamp_str}] {level_str} ", fg=color, nl=False)
489
+ typer.echo(message)
490
+
491
+
492
+ def print_run_logs(logs: Sequence[LogEntry], *, pprint: bool) -> None:
493
+ """
494
+ Prints a sequence of LogEntry objects to the console.
495
+ (This function is unchanged, as the logic is fully
496
+ contained in pretty_print_log.)
497
+ """
498
+ if not logs:
499
+ typer.secho("No logs found for this run.", fg=typer.colors.YELLOW)
500
+ return
501
+
502
+ for log_entry in logs:
503
+ if pprint:
504
+ pretty_print_log(log_entry)
505
+ else:
506
+ typer.echo(f"[{log_entry.timestamp}] {log_entry.message}")
507
+
508
+
509
+ def action_templates_to_table(templates: Sequence[ActionTemplate]) -> Table:
510
+ """Creates a rich Table for a list of ActionTemplates."""
511
+ table = Table(title="Available Action Templates")
512
+
513
+ table.add_column("Name", style="cyan", no_wrap=True)
514
+ table.add_column("ID (UUID)", style="magenta")
515
+ table.add_column("Image Name", style="green")
516
+ table.add_column("Command", style="cyan")
517
+
518
+ for template in templates:
519
+ uuid_text = Text(str(template.uuid), style="magenta")
520
+ table.add_row(template.name, uuid_text, template.image_name, template.command)
521
+
522
+ return table
523
+
524
+
525
+ def print_action_templates_table(templates: Sequence[ActionTemplate], *, pprint: bool) -> None:
526
+ """
527
+ Prints the action templates to stdout
528
+ either using rich or as a simple list of IDs for piping.
529
+ """
530
+ if not templates:
531
+ typer.echo("No action templates found.")
532
+ return
533
+
534
+ if pprint:
535
+ table = action_templates_to_table(templates)
536
+ Console().print(table)
537
+ else:
538
+ for template in templates:
539
+ print(template.uuid)
540
+
541
+
542
+ def follow_run_logs(client: AuthenticatedClient, run_uuid: str) -> int:
543
+ """
544
+ Polls the API for run details and prints new logs as they arrive.
545
+
546
+ Returns:
547
+ An exit code (0 for success, 1 for failure).
548
+ """
549
+ typer.echo(f"Following logs for run {run_uuid}...")
550
+
551
+ TERMINAL_STATES = {"DONE", "FAILED", "UNPROCESSABLE"}
552
+ printed_log_count = 0
553
+ current_run_state = None
554
+ exit_code = 0 # Assume success
555
+
556
+ try:
557
+ while current_run_state not in TERMINAL_STATES:
558
+ try:
559
+ run_details: Run = kleinkram.api.routes.get_run(client, run_uuid)
560
+ current_run_state = run_details.state.upper()
561
+
562
+ # Print only new logs
563
+ new_logs = run_details.logs[printed_log_count:]
564
+ if new_logs:
565
+ # Always pretty-print when following
566
+ print_run_logs(new_logs, pprint=True)
567
+ printed_log_count = len(run_details.logs)
568
+
569
+ if current_run_state in TERMINAL_STATES:
570
+ color = typer.colors.GREEN if run_details.state.upper() == "DONE" else typer.colors.RED
571
+ typer.secho(
572
+ f"\nRun finished with state: {run_details.state} ({run_details.state_cause})",
573
+ fg=color,
574
+ )
575
+ if run_details.state.upper() != "DONE":
576
+ exit_code = 1 # Set failure exit code
577
+ break
578
+
579
+ time.sleep(2) # Poll every 2 seconds
580
+
581
+ except kleinkram.errors.RunNotFound:
582
+ time.sleep(1)
583
+ except httpx.HTTPStatusError as e:
584
+ typer.secho(f"Error fetching run status: {e}", fg=typer.colors.RED)
585
+ time.sleep(5) # Wait longer on API errors
586
+
587
+ except KeyboardInterrupt:
588
+ typer.secho(
589
+ f"\nStopped following logs. Run {run_uuid} is still processing.",
590
+ fg=typer.colors.YELLOW,
591
+ )
592
+ # Return 0, as the command itself wasn't a failure
593
+ return 0
594
+
595
+ return exit_code