kleinkram 0.48.0.dev20250723090520__py3-none-any.whl → 0.58.0.dev20260121112512__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.
- kleinkram/api/client.py +6 -18
- kleinkram/api/deser.py +152 -1
- kleinkram/api/file_transfer.py +57 -87
- kleinkram/api/pagination.py +11 -2
- kleinkram/api/query.py +10 -10
- kleinkram/api/routes.py +192 -59
- kleinkram/auth.py +108 -7
- kleinkram/cli/_action.py +131 -0
- kleinkram/cli/_download.py +6 -18
- kleinkram/cli/_endpoint.py +2 -4
- kleinkram/cli/_file.py +6 -18
- kleinkram/cli/_file_validator.py +125 -0
- kleinkram/cli/_list.py +5 -15
- kleinkram/cli/_mission.py +24 -28
- kleinkram/cli/_project.py +10 -26
- kleinkram/cli/_run.py +220 -0
- kleinkram/cli/_upload.py +58 -26
- kleinkram/cli/_verify.py +48 -15
- kleinkram/cli/app.py +56 -17
- kleinkram/cli/error_handling.py +1 -3
- kleinkram/config.py +6 -21
- kleinkram/core.py +19 -36
- kleinkram/errors.py +12 -0
- kleinkram/models.py +49 -0
- kleinkram/printing.py +225 -15
- kleinkram/utils.py +8 -22
- kleinkram/wrappers.py +13 -34
- {kleinkram-0.48.0.dev20250723090520.dist-info → kleinkram-0.58.0.dev20260121112512.dist-info}/METADATA +6 -5
- kleinkram-0.58.0.dev20260121112512.dist-info/RECORD +53 -0
- {kleinkram-0.48.0.dev20250723090520.dist-info → kleinkram-0.58.0.dev20260121112512.dist-info}/WHEEL +1 -1
- {kleinkram-0.48.0.dev20250723090520.dist-info → kleinkram-0.58.0.dev20260121112512.dist-info}/top_level.txt +0 -1
- {testing → tests}/backend_fixtures.py +27 -3
- tests/conftest.py +1 -1
- tests/generate_test_data.py +322 -0
- tests/test_config.py +2 -6
- tests/test_core.py +11 -31
- tests/test_end_to_end.py +3 -5
- tests/test_fixtures.py +3 -5
- tests/test_printing.py +1 -3
- tests/test_utils.py +1 -3
- tests/test_wrappers.py +9 -27
- kleinkram-0.48.0.dev20250723090520.dist-info/RECORD +0 -50
- testing/__init__.py +0 -0
- {kleinkram-0.48.0.dev20250723090520.dist-info → kleinkram-0.58.0.dev20260121112512.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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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()
|