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.
- kleinkram/api/client.py +6 -18
- kleinkram/api/deser.py +152 -1
- kleinkram/api/file_transfer.py +202 -101
- 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 +8 -19
- 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 +59 -16
- kleinkram/cli/app.py +56 -17
- kleinkram/cli/error_handling.py +1 -3
- kleinkram/config.py +6 -21
- kleinkram/core.py +53 -43
- kleinkram/errors.py +12 -0
- kleinkram/models.py +51 -1
- kleinkram/printing.py +229 -18
- kleinkram/utils.py +10 -24
- kleinkram/wrappers.py +54 -30
- {kleinkram-0.43.2.dev20250331124109.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/METADATA +6 -4
- kleinkram-0.58.0.dev20260110152317.dist-info/RECORD +53 -0
- {kleinkram-0.43.2.dev20250331124109.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/WHEEL +1 -1
- {kleinkram-0.43.2.dev20250331124109.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/top_level.txt +0 -1
- {testing → tests}/backend_fixtures.py +27 -3
- tests/conftest.py +1 -1
- tests/generate_test_data.py +314 -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 +9 -11
- tests/test_utils.py +1 -3
- tests/test_wrappers.py +9 -27
- kleinkram-0.43.2.dev20250331124109.dist-info/RECORD +0 -50
- testing/__init__.py +0 -0
- {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
|
-
|
|
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(
|
|
@@ -133,21 +135,27 @@ def verify(
|
|
|
133
135
|
client: AuthenticatedClient,
|
|
134
136
|
query: MissionQuery,
|
|
135
137
|
file_paths: Sequence[Path],
|
|
136
|
-
skip_hash: bool =
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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.
|
|
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 >=
|
|
88
|
-
fsize /=
|
|
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
|