kleinkram 0.48.0.dev20250723090520__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 +57 -87
  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 +6 -18
  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 +48 -15
  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 +19 -36
  23. kleinkram/errors.py +12 -0
  24. kleinkram/models.py +49 -0
  25. kleinkram/printing.py +225 -15
  26. kleinkram/utils.py +8 -22
  27. kleinkram/wrappers.py +13 -34
  28. {kleinkram-0.48.0.dev20250723090520.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/METADATA +6 -5
  29. kleinkram-0.58.0.dev20260110152317.dist-info/RECORD +53 -0
  30. {kleinkram-0.48.0.dev20250723090520.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/top_level.txt +0 -1
  31. {testing → tests}/backend_fixtures.py +27 -3
  32. tests/conftest.py +1 -1
  33. tests/generate_test_data.py +314 -0
  34. tests/test_config.py +2 -6
  35. tests/test_core.py +11 -31
  36. tests/test_end_to_end.py +3 -5
  37. tests/test_fixtures.py +3 -5
  38. tests/test_printing.py +1 -3
  39. tests/test_utils.py +1 -3
  40. tests/test_wrappers.py +9 -27
  41. kleinkram-0.48.0.dev20250723090520.dist-info/RECORD +0 -50
  42. testing/__init__.py +0 -0
  43. {kleinkram-0.48.0.dev20250723090520.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/WHEEL +0 -0
  44. {kleinkram-0.48.0.dev20250723090520.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(
@@ -153,12 +155,7 @@ def verify(
153
155
  # check that the mission exists
154
156
  _ = kleinkram.api.routes.get_mission(client, query)
155
157
 
156
- remote_files = {
157
- f.name: f
158
- for f in kleinkram.api.routes.get_files(
159
- client, file_query=FileQuery(mission_query=query)
160
- )
161
- }
158
+ remote_files = {f.name: f for f in kleinkram.api.routes.get_files(client, file_query=FileQuery(mission_query=query))}
162
159
  filename_map = get_filename_map(file_paths)
163
160
 
164
161
  # verify files
@@ -213,9 +210,7 @@ def update_file(*, client: AuthenticatedClient, file_id: UUID) -> None:
213
210
  raise NotImplementedError("if you have an idea what this should do, open an issue")
214
211
 
215
212
 
216
- def update_mission(
217
- *, client: AuthenticatedClient, mission_id: UUID, metadata: Dict[str, str]
218
- ) -> None:
213
+ def update_mission(*, client: AuthenticatedClient, mission_id: UUID, metadata: Dict[str, str]) -> None:
219
214
  # TODO: this funciton will do more than just overwirte the metadata in the future
220
215
  kleinkram.api.routes._update_mission(client, mission_id, metadata=metadata)
221
216
 
@@ -228,9 +223,7 @@ def update_project(
228
223
  new_name: Optional[str] = None,
229
224
  ) -> None:
230
225
  # TODO: this function should do more in the future
231
- kleinkram.api.routes._update_project(
232
- client, project_id, description=description, new_name=new_name
233
- )
226
+ kleinkram.api.routes._update_project(client, project_id, description=description, new_name=new_name)
234
227
 
235
228
 
236
229
  def delete_files(*, client: AuthenticatedClient, file_ids: Collection[UUID]) -> None:
@@ -247,9 +240,7 @@ def delete_files(*, client: AuthenticatedClient, file_ids: Collection[UUID]) ->
247
240
  found_ids = [f.id for f in files]
248
241
  for file_id in file_ids:
249
242
  if file_id not in found_ids:
250
- raise kleinkram.errors.FileNotFound(
251
- f"file {file_id} not found, did not delete any files"
252
- )
243
+ raise kleinkram.errors.FileNotFound(f"file {file_id} not found, did not delete any files")
253
244
 
254
245
  # to prevent catastrophic mistakes from happening *again*
255
246
  assert set(file_ids) == set([file.id for file in files]), "unreachable"
@@ -268,11 +259,7 @@ def delete_files(*, client: AuthenticatedClient, file_ids: Collection[UUID]) ->
268
259
  def delete_mission(*, client: AuthenticatedClient, mission_id: UUID) -> None:
269
260
  mquery = MissionQuery(ids=[mission_id])
270
261
  mission = kleinkram.api.routes.get_mission(client, mquery)
271
- files = list(
272
- kleinkram.api.routes.get_files(
273
- client, file_query=FileQuery(mission_query=mquery)
274
- )
275
- )
262
+ files = list(kleinkram.api.routes.get_files(client, file_query=FileQuery(mission_query=mquery)))
276
263
 
277
264
  # delete the files and then the mission
278
265
  kleinkram.api.routes._delete_files(client, [f.id for f in files], mission.id)
@@ -281,14 +268,10 @@ def delete_mission(*, client: AuthenticatedClient, mission_id: UUID) -> None:
281
268
 
282
269
  def delete_project(*, client: AuthenticatedClient, project_id: UUID) -> None:
283
270
  pquery = ProjectQuery(ids=[project_id])
284
- _ = 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
285
272
 
286
273
  # delete all missions and files
287
- missions = list(
288
- kleinkram.api.routes.get_missions(
289
- client, mission_query=MissionQuery(project_query=pquery)
290
- )
291
- )
274
+ missions = list(kleinkram.api.routes.get_missions(client, mission_query=MissionQuery(project_query=pquery)))
292
275
  for mission in missions:
293
276
  delete_mission(client=client, mission_id=mission.id)
294
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,6 +77,53 @@ 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
129
  UPLOADED = "uploaded"
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",
@@ -166,9 +175,7 @@ def missions_to_table(missions: Sequence[Mission]) -> Table:
166
175
  return table
167
176
 
168
177
 
169
- def files_to_table(
170
- files: Sequence[File], *, title: str = "files", delimiters: bool = True
171
- ) -> Table:
178
+ def files_to_table(files: Sequence[File], *, title: str = "files", delimiters: bool = True) -> Table:
172
179
  table = Table(title=title)
173
180
  table.add_column("project")
174
181
  table.add_column("mission")
@@ -232,9 +239,7 @@ def file_info_table(file: File) -> Table:
232
239
  return table
233
240
 
234
241
 
235
- def mission_info_table(
236
- mission: Mission, print_metadata: bool = False
237
- ) -> Tuple[Table, ...]:
242
+ def mission_info_table(mission: Mission, print_metadata: bool = True) -> Tuple[Table, ...]:
238
243
  table = Table("k", "v", title=f"mission info: {mission.name}", show_header=False)
239
244
 
240
245
  # TODO: add more fields as we store more information in the Mission object
@@ -251,9 +256,7 @@ def mission_info_table(
251
256
  return (table,)
252
257
 
253
258
  metadata_table = Table("k", "v", title="mission metadata", show_header=False)
254
- kv_pairs_sorted = sorted(
255
- [(k, v) for k, v in mission.metadata.items()], key=lambda x: x[0]
256
- )
259
+ kv_pairs_sorted = sorted([(k, v) for k, v in mission.metadata.items()], key=lambda x: x[0])
257
260
  for k, v in kv_pairs_sorted:
258
261
  metadata_table.add_row(k, str(parse_metadata_value(v)))
259
262
 
@@ -269,6 +272,7 @@ def project_info_table(project: Project) -> Table:
269
272
  table.add_row("description", project.description)
270
273
  table.add_row("created", str(project.created_at))
271
274
  table.add_row("updated", str(project.updated_at))
275
+ table.add_row("required tags", ", ".join(project.required_tags))
272
276
 
273
277
  return table
274
278
 
@@ -284,9 +288,7 @@ def file_verification_status_table(
284
288
  return table
285
289
 
286
290
 
287
- def print_file_verification_status(
288
- file_status: Mapping[Path, FileVerificationStatus], *, pprint: bool
289
- ) -> None:
291
+ def print_file_verification_status(file_status: Mapping[Path, FileVerificationStatus], *, pprint: bool) -> None:
290
292
  """\
291
293
  prints the file verification status to stdout / stderr
292
294
  either using pprint or as a list for piping
@@ -296,9 +298,7 @@ def print_file_verification_status(
296
298
  Console().print(table)
297
299
  else:
298
300
  for path, status in file_status.items():
299
- stream = (
300
- sys.stdout if status == FileVerificationStatus.UPLOADED else sys.stderr
301
- )
301
+ stream = sys.stdout if status == FileVerificationStatus.UPLOADED else sys.stderr
302
302
  print(path, file=stream, flush=True)
303
303
 
304
304
 
@@ -383,3 +383,213 @@ def print_project_info(project: Project, *, pprint: bool) -> None:
383
383
  for key in project_dct:
384
384
  project_dct[key] = str(project_dct[key]) # TODO: improve this
385
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
kleinkram/utils.py CHANGED
@@ -28,15 +28,11 @@ from kleinkram.types import IdLike
28
28
  from kleinkram.types import PathLike
29
29
 
30
30
  INTERNAL_ALLOWED_CHARS = string.ascii_letters + string.digits + "_" + "-"
31
- SUPPORT_FILE_TYPES = [
32
- ".bag",
33
- ".mcap",
34
- ]
31
+ SUPPORT_FILE_TYPES = [".bag", ".mcap", ".db3", ".svo2", ".tum", ".yaml", ".yml"]
32
+ EXPERIMENTAL_FILE_TYPES = []
35
33
 
36
34
 
37
- def file_paths_from_files(
38
- files: Sequence[File], *, dest: Path, allow_nested: bool = False
39
- ) -> Dict[Path, File]:
35
+ def file_paths_from_files(files: Sequence[File], *, dest: Path, allow_nested: bool = False) -> Dict[Path, File]:
40
36
  """\
41
37
  determines the destinations for a sequence of `File` objects,
42
38
  possibly nested by project and mission
@@ -46,10 +42,7 @@ def file_paths_from_files(
46
42
  elif not allow_nested:
47
43
  return {dest / file.name: file for file in files}
48
44
  else:
49
- return {
50
- dest / file.project_name / file.mission_name / file.name: file
51
- for file in files
52
- }
45
+ return {dest / file.project_name / file.mission_name / file.name: file for file in files}
53
46
 
54
47
 
55
48
  def upper_camel_case_to_words(s: str) -> List[str]:
@@ -89,13 +82,10 @@ def check_file_path(file: Path) -> None:
89
82
  if not file.exists():
90
83
  raise FileNotFoundError(f"{file} does not exist")
91
84
  if file.suffix not in SUPPORT_FILE_TYPES:
92
- raise FileTypeNotSupported(
93
- f"only {', '.join(SUPPORT_FILE_TYPES)} files are supported: {file}"
94
- )
85
+ raise FileTypeNotSupported(f"only {', '.join(SUPPORT_FILE_TYPES)} files are supported: {file}")
95
86
  if not check_filename_is_sanatized(file.stem):
96
87
  raise FileNameNotSupported(
97
- f"only `{''.join(INTERNAL_ALLOWED_CHARS)}` are "
98
- f"allowed in filenames and at most 50chars: {file}"
88
+ f"only `{''.join(INTERNAL_ALLOWED_CHARS)}` are " f"allowed in filenames and at most 50chars: {file}"
99
89
  )
100
90
 
101
91
 
@@ -108,9 +98,7 @@ def format_error(msg: str, exc: Exception, *, verbose: bool = False) -> str:
108
98
 
109
99
 
110
100
  def format_traceback(exc: Exception) -> str:
111
- return "".join(
112
- traceback.format_exception(type(exc), value=exc, tb=exc.__traceback__)
113
- )
101
+ return "".join(traceback.format_exception(type(exc), value=exc, tb=exc.__traceback__))
114
102
 
115
103
 
116
104
  def styled_string(*objects: Any, **kwargs: Any) -> str:
@@ -150,9 +138,7 @@ def get_filename(path: Path) -> str:
150
138
  - the 10 hashed chars are deterministic given the original filename
151
139
  """
152
140
 
153
- stem = "".join(
154
- char if char in INTERNAL_ALLOWED_CHARS else "_" for char in path.stem
155
- )
141
+ stem = "".join(char if char in INTERNAL_ALLOWED_CHARS else "_" for char in path.stem)
156
142
 
157
143
  if len(stem) > 50:
158
144
  hash = md5(path.name.encode()).hexdigest()