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.
- 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.dev20260110152317.dist-info}/METADATA +6 -5
- kleinkram-0.58.0.dev20260110152317.dist-info/RECORD +53 -0
- {kleinkram-0.48.0.dev20250723090520.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 +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.dev20260110152317.dist-info}/WHEEL +0 -0
- {kleinkram-0.48.0.dev20250723090520.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/entry_points.txt +0 -0
kleinkram/api/client.py
CHANGED
|
@@ -39,15 +39,11 @@ ListData = Sequence[Data]
|
|
|
39
39
|
QueryParams = Mapping[str, Union[Data, NestedData, ListData]]
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
def _convert_nested_data_query_params_values(
|
|
43
|
-
key: str, values: NestedData
|
|
44
|
-
) -> List[Tuple[str, Data]]:
|
|
42
|
+
def _convert_nested_data_query_params_values(key: str, values: NestedData) -> List[Tuple[str, Data]]:
|
|
45
43
|
return [(f"{key}[{k}]", v) for k, v in values.items()]
|
|
46
44
|
|
|
47
45
|
|
|
48
|
-
def _convert_list_data_query_params_values(
|
|
49
|
-
key: str, values: ListData
|
|
50
|
-
) -> List[Tuple[str, Data]]:
|
|
46
|
+
def _convert_list_data_query_params_values(key: str, values: ListData) -> List[Tuple[str, Data]]:
|
|
51
47
|
return [(key, value) for value in values]
|
|
52
48
|
|
|
53
49
|
|
|
@@ -71,9 +67,7 @@ class AuthenticatedClient(httpx.Client):
|
|
|
71
67
|
_config: Config
|
|
72
68
|
_config_lock: Lock
|
|
73
69
|
|
|
74
|
-
def __init__(
|
|
75
|
-
self, config_path: Path = CONFIG_PATH, *args: Any, **kwargs: Any
|
|
76
|
-
) -> None:
|
|
70
|
+
def __init__(self, config_path: Path = CONFIG_PATH, *args: Any, **kwargs: Any) -> None:
|
|
77
71
|
super().__init__(*args, **kwargs)
|
|
78
72
|
|
|
79
73
|
self._config = get_config(path=config_path)
|
|
@@ -116,9 +110,7 @@ class AuthenticatedClient(httpx.Client):
|
|
|
116
110
|
|
|
117
111
|
self.cookies.set(COOKIE_AUTH_TOKEN, new_access_token)
|
|
118
112
|
|
|
119
|
-
def _send_request_with_kleinkram_headers(
|
|
120
|
-
self, *args: Any, **kwargs: Any
|
|
121
|
-
) -> httpx.Response:
|
|
113
|
+
def _send_request_with_kleinkram_headers(self, *args: Any, **kwargs: Any) -> httpx.Response:
|
|
122
114
|
# add the cli version to the headers
|
|
123
115
|
headers = kwargs.get("headers") or {}
|
|
124
116
|
headers.setdefault(CLI_VERSION_HEADER, __version__)
|
|
@@ -150,9 +142,7 @@ class AuthenticatedClient(httpx.Client):
|
|
|
150
142
|
logger.info(f"requesting {method} {full_url}")
|
|
151
143
|
|
|
152
144
|
httpx_params = _convert_query_params_to_httpx_format(params or {})
|
|
153
|
-
response = self._send_request_with_kleinkram_headers(
|
|
154
|
-
method, full_url, params=httpx_params, *args, **kwargs
|
|
155
|
-
)
|
|
145
|
+
response = self._send_request_with_kleinkram_headers(method, full_url, params=httpx_params, *args, **kwargs)
|
|
156
146
|
|
|
157
147
|
logger.info(f"got response {response}")
|
|
158
148
|
|
|
@@ -170,9 +160,7 @@ class AuthenticatedClient(httpx.Client):
|
|
|
170
160
|
raise NotAuthenticated
|
|
171
161
|
|
|
172
162
|
logger.info(f"retrying request {method} {full_url}")
|
|
173
|
-
response = self._send_request_with_kleinkram_headers(
|
|
174
|
-
method, full_url, params=httpx_params, *args, **kwargs
|
|
175
|
-
)
|
|
163
|
+
response = self._send_request_with_kleinkram_headers(method, full_url, params=httpx_params, *args, **kwargs)
|
|
176
164
|
logger.info(f"got response {response}")
|
|
177
165
|
return response
|
|
178
166
|
else:
|
kleinkram/api/deser.py
CHANGED
|
@@ -4,6 +4,7 @@ from datetime import datetime
|
|
|
4
4
|
from enum import Enum
|
|
5
5
|
from typing import Any
|
|
6
6
|
from typing import Dict
|
|
7
|
+
from typing import List
|
|
7
8
|
from typing import Literal
|
|
8
9
|
from typing import NewType
|
|
9
10
|
from typing import Tuple
|
|
@@ -12,10 +13,14 @@ from uuid import UUID
|
|
|
12
13
|
import dateutil.parser
|
|
13
14
|
|
|
14
15
|
from kleinkram.errors import ParsingError
|
|
16
|
+
from kleinkram.models import ActionTemplate
|
|
15
17
|
from kleinkram.models import File
|
|
16
18
|
from kleinkram.models import FileState
|
|
19
|
+
from kleinkram.models import LogEntry
|
|
20
|
+
from kleinkram.models import MetadataValue
|
|
17
21
|
from kleinkram.models import Mission
|
|
18
22
|
from kleinkram.models import Project
|
|
23
|
+
from kleinkram.models import Run
|
|
19
24
|
|
|
20
25
|
__all__ = [
|
|
21
26
|
"_parse_project",
|
|
@@ -27,6 +32,7 @@ __all__ = [
|
|
|
27
32
|
ProjectObject = NewType("ProjectObject", Dict[str, Any])
|
|
28
33
|
MissionObject = NewType("MissionObject", Dict[str, Any])
|
|
29
34
|
FileObject = NewType("FileObject", Dict[str, Any])
|
|
35
|
+
RunObject = NewType("RunObject", Dict[str, Any])
|
|
30
36
|
|
|
31
37
|
MISSION = "mission"
|
|
32
38
|
PROJECT = "project"
|
|
@@ -51,6 +57,9 @@ class MissionObjectKeys(str, Enum):
|
|
|
51
57
|
DESCRIPTION = "description"
|
|
52
58
|
CREATED_AT = "createdAt"
|
|
53
59
|
UPDATED_AT = "updatedAt"
|
|
60
|
+
TAGS = "tags"
|
|
61
|
+
FILESIZE = "size"
|
|
62
|
+
FILECOUNT = "filesCount"
|
|
54
63
|
|
|
55
64
|
|
|
56
65
|
class ProjectObjectKeys(str, Enum):
|
|
@@ -59,6 +68,40 @@ class ProjectObjectKeys(str, Enum):
|
|
|
59
68
|
DESCRIPTION = "description"
|
|
60
69
|
CREATED_AT = "createdAt"
|
|
61
70
|
UPDATED_AT = "updatedAt"
|
|
71
|
+
REQUIRED_TAGS = "requiredTags"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RunObjectKeys(str, Enum):
|
|
75
|
+
UUID = "uuid"
|
|
76
|
+
STATE = "state"
|
|
77
|
+
STATE_CAUSE = "stateCause"
|
|
78
|
+
CREATED_AT = "createdAt"
|
|
79
|
+
MISSION = "mission"
|
|
80
|
+
TEMPLATE = "template"
|
|
81
|
+
UPDATED_AT = "updatedAt"
|
|
82
|
+
LOGS = "logs"
|
|
83
|
+
ARTIFACT_URL = "artifactUrl"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TemplateObjectKeys(str, Enum):
|
|
87
|
+
UUID = "uuid"
|
|
88
|
+
NAME = "name"
|
|
89
|
+
ACCESS_RIGHTS = "accessRights"
|
|
90
|
+
COMMAND = "command"
|
|
91
|
+
CPU_CORES = "cpuCores"
|
|
92
|
+
CPU_MEMORY_GB = "cpuMemory"
|
|
93
|
+
ENTRYPOINT = "entrypoint"
|
|
94
|
+
GPU_MEMORY_GB = "gpuMemory"
|
|
95
|
+
IMAGE_NAME = "imageName"
|
|
96
|
+
MAX_RUNTIME_MINUTES = "maxRuntime"
|
|
97
|
+
CREATED_AT = "createdAt"
|
|
98
|
+
VERSION = "version"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class LogEntryObjectKeys(str, Enum):
|
|
102
|
+
TIMESTAMP = "timestamp"
|
|
103
|
+
LEVEL = "type"
|
|
104
|
+
MESSAGE = "message"
|
|
62
105
|
|
|
63
106
|
|
|
64
107
|
def _get_nested_info(data, key: Literal["mission", "project"]) -> Tuple[UUID, str]:
|
|
@@ -83,6 +126,21 @@ def _parse_file_state(state: str) -> FileState:
|
|
|
83
126
|
raise ParsingError(f"error parsing file state: {state}") from e
|
|
84
127
|
|
|
85
128
|
|
|
129
|
+
def _parse_metadata(tags: List[Dict]) -> Dict[str, MetadataValue]:
|
|
130
|
+
result = {}
|
|
131
|
+
try:
|
|
132
|
+
for tag in tags:
|
|
133
|
+
entry = {tag.get("name"): MetadataValue(tag.get("valueAsString"), tag.get("datatype"))}
|
|
134
|
+
result.update(entry)
|
|
135
|
+
return result
|
|
136
|
+
except ValueError as e:
|
|
137
|
+
raise ParsingError(f"error parsing metadata: {e}") from e
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _parse_required_tags(tags: List[Dict]) -> list[str]:
|
|
141
|
+
return list(_parse_metadata(tags).keys())
|
|
142
|
+
|
|
143
|
+
|
|
86
144
|
def _parse_project(project_object: ProjectObject) -> Project:
|
|
87
145
|
try:
|
|
88
146
|
id_ = UUID(project_object[ProjectObjectKeys.UUID], version=4)
|
|
@@ -90,6 +148,7 @@ def _parse_project(project_object: ProjectObject) -> Project:
|
|
|
90
148
|
description = project_object[ProjectObjectKeys.DESCRIPTION]
|
|
91
149
|
created_at = _parse_datetime(project_object[ProjectObjectKeys.CREATED_AT])
|
|
92
150
|
updated_at = _parse_datetime(project_object[ProjectObjectKeys.UPDATED_AT])
|
|
151
|
+
required_tags = _parse_required_tags(project_object[ProjectObjectKeys.REQUIRED_TAGS])
|
|
93
152
|
except Exception as e:
|
|
94
153
|
raise ParsingError(f"error parsing project: {project_object}") from e
|
|
95
154
|
return Project(
|
|
@@ -98,6 +157,7 @@ def _parse_project(project_object: ProjectObject) -> Project:
|
|
|
98
157
|
description=description,
|
|
99
158
|
created_at=created_at,
|
|
100
159
|
updated_at=updated_at,
|
|
160
|
+
required_tags=required_tags,
|
|
101
161
|
)
|
|
102
162
|
|
|
103
163
|
|
|
@@ -107,7 +167,9 @@ def _parse_mission(mission: MissionObject) -> Mission:
|
|
|
107
167
|
name = mission[MissionObjectKeys.NAME]
|
|
108
168
|
created_at = _parse_datetime(mission[MissionObjectKeys.CREATED_AT])
|
|
109
169
|
updated_at = _parse_datetime(mission[MissionObjectKeys.UPDATED_AT])
|
|
110
|
-
metadata =
|
|
170
|
+
metadata = _parse_metadata(mission[MissionObjectKeys.TAGS])
|
|
171
|
+
file_count = mission[MissionObjectKeys.FILECOUNT]
|
|
172
|
+
filesize = mission[MissionObjectKeys.FILESIZE]
|
|
111
173
|
|
|
112
174
|
project_id, project_name = _get_nested_info(mission, PROJECT)
|
|
113
175
|
|
|
@@ -119,6 +181,8 @@ def _parse_mission(mission: MissionObject) -> Mission:
|
|
|
119
181
|
metadata=metadata,
|
|
120
182
|
project_id=project_id,
|
|
121
183
|
project_name=project_name,
|
|
184
|
+
number_of_files=file_count,
|
|
185
|
+
size=filesize,
|
|
122
186
|
)
|
|
123
187
|
except Exception as e:
|
|
124
188
|
raise ParsingError(f"error parsing mission: {mission}") from e
|
|
@@ -160,3 +224,90 @@ def _parse_file(file: FileObject) -> File:
|
|
|
160
224
|
except Exception as e:
|
|
161
225
|
raise ParsingError(f"error parsing file: {file}") from e
|
|
162
226
|
return parsed
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _parse_action_template(run_object: RunObject) -> ActionTemplate:
|
|
230
|
+
try:
|
|
231
|
+
uuid_ = UUID(run_object[TemplateObjectKeys.UUID], version=4)
|
|
232
|
+
access_rights = run_object[TemplateObjectKeys.ACCESS_RIGHTS]
|
|
233
|
+
command = run_object[TemplateObjectKeys.COMMAND]
|
|
234
|
+
cpu_cores = run_object[TemplateObjectKeys.CPU_CORES]
|
|
235
|
+
cpu_memory_gb = run_object[TemplateObjectKeys.CPU_MEMORY_GB]
|
|
236
|
+
entrypoint = run_object[TemplateObjectKeys.ENTRYPOINT]
|
|
237
|
+
gpu_memory_gb = run_object[TemplateObjectKeys.GPU_MEMORY_GB]
|
|
238
|
+
image_name = run_object[TemplateObjectKeys.IMAGE_NAME]
|
|
239
|
+
max_runtime_minutes = run_object[TemplateObjectKeys.MAX_RUNTIME_MINUTES]
|
|
240
|
+
created_at = _parse_datetime(run_object[TemplateObjectKeys.CREATED_AT])
|
|
241
|
+
name = run_object[TemplateObjectKeys.NAME]
|
|
242
|
+
version = run_object[TemplateObjectKeys.VERSION]
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
raise ParsingError(f"error parsing action template: {run_object}") from e
|
|
246
|
+
|
|
247
|
+
return ActionTemplate(
|
|
248
|
+
uuid=uuid_,
|
|
249
|
+
access_rights=access_rights,
|
|
250
|
+
command=command,
|
|
251
|
+
cpu_cores=cpu_cores,
|
|
252
|
+
cpu_memory_gb=cpu_memory_gb,
|
|
253
|
+
entrypoint=entrypoint,
|
|
254
|
+
gpu_memory_gb=gpu_memory_gb,
|
|
255
|
+
image_name=image_name,
|
|
256
|
+
max_runtime_minutes=max_runtime_minutes,
|
|
257
|
+
created_at=created_at,
|
|
258
|
+
name=name,
|
|
259
|
+
version=version,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _parse_run(run_object: RunObject) -> Run:
|
|
264
|
+
try:
|
|
265
|
+
uuid_ = UUID(run_object[RunObjectKeys.UUID], version=4)
|
|
266
|
+
state = run_object[RunObjectKeys.STATE]
|
|
267
|
+
state_cause = run_object[RunObjectKeys.STATE_CAUSE]
|
|
268
|
+
artifact_url = run_object.get(RunObjectKeys.ARTIFACT_URL)
|
|
269
|
+
created_at = _parse_datetime(run_object[RunObjectKeys.CREATED_AT])
|
|
270
|
+
updated_at = (
|
|
271
|
+
_parse_datetime(run_object[RunObjectKeys.UPDATED_AT]) if run_object.get(RunObjectKeys.UPDATED_AT) else None
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
mission_dict = run_object[RunObjectKeys.MISSION]
|
|
275
|
+
mission_id = UUID(mission_dict[MissionObjectKeys.UUID], version=4)
|
|
276
|
+
mission_name = mission_dict[MissionObjectKeys.NAME]
|
|
277
|
+
|
|
278
|
+
project_dict = mission_dict[PROJECT]
|
|
279
|
+
project_name = project_dict[ProjectObjectKeys.NAME]
|
|
280
|
+
|
|
281
|
+
template_dict = run_object[RunObjectKeys.TEMPLATE]
|
|
282
|
+
template_id = UUID(template_dict[TemplateObjectKeys.UUID], version=4)
|
|
283
|
+
template_name = template_dict[TemplateObjectKeys.NAME]
|
|
284
|
+
logs = []
|
|
285
|
+
for log_entry in run_object.get(RunObjectKeys.LOGS, []):
|
|
286
|
+
log_timestamp = _parse_datetime(log_entry[LogEntryObjectKeys.TIMESTAMP])
|
|
287
|
+
log_level = log_entry[LogEntryObjectKeys.LEVEL]
|
|
288
|
+
log_message = log_entry[LogEntryObjectKeys.MESSAGE]
|
|
289
|
+
logs.append(
|
|
290
|
+
LogEntry(
|
|
291
|
+
timestamp=log_timestamp,
|
|
292
|
+
level=log_level,
|
|
293
|
+
message=log_message,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
except Exception as e:
|
|
298
|
+
raise ParsingError(f"error parsing run: {run_object}") from e
|
|
299
|
+
|
|
300
|
+
return Run(
|
|
301
|
+
uuid=uuid_,
|
|
302
|
+
state=state,
|
|
303
|
+
state_cause=state_cause,
|
|
304
|
+
artifact_url=artifact_url,
|
|
305
|
+
created_at=created_at,
|
|
306
|
+
updated_at=updated_at,
|
|
307
|
+
mission_id=mission_id,
|
|
308
|
+
mission_name=mission_name,
|
|
309
|
+
project_name=project_name,
|
|
310
|
+
template_id=template_id,
|
|
311
|
+
template_name=template_name,
|
|
312
|
+
logs=logs,
|
|
313
|
+
)
|
kleinkram/api/file_transfer.py
CHANGED
|
@@ -7,7 +7,8 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
7
7
|
from concurrent.futures import as_completed
|
|
8
8
|
from enum import Enum
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from time import monotonic
|
|
10
|
+
from time import monotonic
|
|
11
|
+
from time import sleep
|
|
11
12
|
from typing import Dict
|
|
12
13
|
from typing import NamedTuple
|
|
13
14
|
from typing import Optional
|
|
@@ -34,7 +35,7 @@ from kleinkram.utils import styled_string
|
|
|
34
35
|
logger = logging.getLogger(__name__)
|
|
35
36
|
|
|
36
37
|
UPLOAD_CREDS = "/files/temporaryAccess"
|
|
37
|
-
UPLOAD_CONFIRM = "/
|
|
38
|
+
UPLOAD_CONFIRM = "/files/upload/confirm"
|
|
38
39
|
UPLOAD_CANCEL = "/files/cancelUpload"
|
|
39
40
|
|
|
40
41
|
DOWNLOAD_CHUNK_SIZE = 1024 * 1024 * 16
|
|
@@ -56,20 +57,17 @@ class UploadCredentials(NamedTuple):
|
|
|
56
57
|
bucket: str
|
|
57
58
|
|
|
58
59
|
|
|
59
|
-
def _confirm_file_upload(
|
|
60
|
-
client: AuthenticatedClient, file_id: UUID, file_hash: str
|
|
61
|
-
) -> None:
|
|
60
|
+
def _confirm_file_upload(client: AuthenticatedClient, file_id: UUID, file_hash: str) -> None:
|
|
62
61
|
data = {
|
|
63
62
|
"uuid": str(file_id),
|
|
64
63
|
"md5": file_hash,
|
|
64
|
+
"source": "CLI",
|
|
65
65
|
}
|
|
66
66
|
resp = client.post(UPLOAD_CONFIRM, json=data)
|
|
67
67
|
resp.raise_for_status()
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
def _cancel_file_upload(
|
|
71
|
-
client: AuthenticatedClient, file_id: UUID, mission_id: UUID
|
|
72
|
-
) -> None:
|
|
70
|
+
def _cancel_file_upload(client: AuthenticatedClient, file_id: UUID, mission_id: UUID) -> None:
|
|
73
71
|
data = {
|
|
74
72
|
"uuids": [str(file_id)],
|
|
75
73
|
"missionUuid": str(mission_id),
|
|
@@ -96,9 +94,16 @@ def _get_upload_creditials(
|
|
|
96
94
|
dct = {
|
|
97
95
|
"filenames": [internal_filename],
|
|
98
96
|
"missionUUID": str(mission_id),
|
|
97
|
+
"source": "CLI",
|
|
99
98
|
}
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
try:
|
|
100
|
+
resp = client.post(UPLOAD_CREDS, json=dct)
|
|
101
|
+
resp.raise_for_status()
|
|
102
|
+
except httpx.HTTPStatusError as e:
|
|
103
|
+
# 409 Conflict means file already exists
|
|
104
|
+
if e.response.status_code == 409:
|
|
105
|
+
return None
|
|
106
|
+
raise
|
|
102
107
|
|
|
103
108
|
data = resp.json()["data"][0]
|
|
104
109
|
|
|
@@ -156,9 +161,7 @@ class UploadState(Enum):
|
|
|
156
161
|
CANCELED = 3
|
|
157
162
|
|
|
158
163
|
|
|
159
|
-
def _get_upload_credentials_with_retry(
|
|
160
|
-
client, pbar, filename, mission_id, max_attempts=5
|
|
161
|
-
):
|
|
164
|
+
def _get_upload_credentials_with_retry(client, pbar, filename, mission_id, max_attempts=5):
|
|
162
165
|
"""
|
|
163
166
|
Retrieves upload credentials with retry logic.
|
|
164
167
|
|
|
@@ -173,9 +176,7 @@ def _get_upload_credentials_with_retry(
|
|
|
173
176
|
"""
|
|
174
177
|
attempt = 0
|
|
175
178
|
while attempt < max_attempts:
|
|
176
|
-
creds = _get_upload_creditials(
|
|
177
|
-
client, internal_filename=filename, mission_id=mission_id
|
|
178
|
-
)
|
|
179
|
+
creds = _get_upload_creditials(client, internal_filename=filename, mission_id=mission_id)
|
|
179
180
|
if creds is not None:
|
|
180
181
|
return creds
|
|
181
182
|
|
|
@@ -230,9 +231,7 @@ def upload_file(
|
|
|
230
231
|
try:
|
|
231
232
|
_cancel_file_upload(client, creds.file_id, mission_id)
|
|
232
233
|
except Exception as cancel_e:
|
|
233
|
-
logger.error(
|
|
234
|
-
f"Failed to cancel upload for {creds.file_id}: {cancel_e}"
|
|
235
|
-
)
|
|
234
|
+
logger.error(f"Failed to cancel upload for {creds.file_id}: {cancel_e}")
|
|
236
235
|
|
|
237
236
|
if attempt < 2: # Retry if not the last attempt
|
|
238
237
|
pbar.update(0)
|
|
@@ -251,22 +250,19 @@ def _get_file_download(client: AuthenticatedClient, id: UUID) -> str:
|
|
|
251
250
|
"""\
|
|
252
251
|
get the download url for a file by file id
|
|
253
252
|
"""
|
|
254
|
-
resp = client.get(DOWNLOAD_URL, params={"uuid": str(id), "expires": True})
|
|
253
|
+
resp = client.get(DOWNLOAD_URL, params={"uuid": str(id), "expires": True, "preview_only": False})
|
|
255
254
|
|
|
256
255
|
if 400 <= resp.status_code < 500:
|
|
257
256
|
raise AccessDenied(
|
|
258
|
-
f"Failed to download file: {resp.json()['message']}"
|
|
259
|
-
f" Status Code: {resp.status_code}",
|
|
257
|
+
f"Failed to download file: {resp.json()['message']}" f" Status Code: {resp.status_code}",
|
|
260
258
|
)
|
|
261
259
|
|
|
262
260
|
resp.raise_for_status()
|
|
263
261
|
|
|
264
|
-
return resp.
|
|
262
|
+
return resp.json()["url"]
|
|
265
263
|
|
|
266
264
|
|
|
267
|
-
def _url_download(
|
|
268
|
-
url: str, *, path: Path, size: int, overwrite: bool = False, verbose: bool = False
|
|
269
|
-
) -> None:
|
|
265
|
+
def _url_download(url: str, *, path: Path, size: int, overwrite: bool = False, verbose: bool = False) -> None:
|
|
270
266
|
if path.exists():
|
|
271
267
|
if overwrite:
|
|
272
268
|
path.unlink()
|
|
@@ -282,18 +278,11 @@ def _url_download(
|
|
|
282
278
|
while downloaded < size:
|
|
283
279
|
try:
|
|
284
280
|
headers = {"Range": f"bytes={downloaded}-"}
|
|
285
|
-
with httpx.stream(
|
|
286
|
-
"GET", url, headers=headers, timeout=S3_READ_TIMEOUT
|
|
287
|
-
) as response:
|
|
281
|
+
with httpx.stream("GET", url, headers=headers, timeout=S3_READ_TIMEOUT) as response:
|
|
288
282
|
# Accept both 206 Partial Content and 200 OK if starting from 0
|
|
289
|
-
if not (
|
|
290
|
-
response.status_code == 206
|
|
291
|
-
or (downloaded == 0 and response.status_code == 200)
|
|
292
|
-
):
|
|
283
|
+
if not (response.status_code == 206 or (downloaded == 0 and response.status_code == 200)):
|
|
293
284
|
response.raise_for_status()
|
|
294
|
-
raise RuntimeError(
|
|
295
|
-
f"Expected 206 Partial Content, got {response.status_code}"
|
|
296
|
-
)
|
|
285
|
+
raise RuntimeError(f"Expected 206 Partial Content, got {response.status_code}")
|
|
297
286
|
|
|
298
287
|
mode = "ab" if downloaded > 0 else "wb"
|
|
299
288
|
with open(path, mode) as f:
|
|
@@ -306,9 +295,7 @@ def _url_download(
|
|
|
306
295
|
leave=False,
|
|
307
296
|
disable=not verbose,
|
|
308
297
|
) as pbar:
|
|
309
|
-
for chunk in response.iter_bytes(
|
|
310
|
-
chunk_size=DOWNLOAD_CHUNK_SIZE
|
|
311
|
-
):
|
|
298
|
+
for chunk in response.iter_bytes(chunk_size=DOWNLOAD_CHUNK_SIZE):
|
|
312
299
|
attempt = 0 # reset attempt counter on successful download of non-empty chunk
|
|
313
300
|
if not chunk:
|
|
314
301
|
break
|
|
@@ -320,13 +307,9 @@ def _url_download(
|
|
|
320
307
|
logger.info(f"Error: {e}, retrying...")
|
|
321
308
|
attempt += 1
|
|
322
309
|
if attempt > MAX_RETRIES:
|
|
323
|
-
raise RuntimeError(
|
|
324
|
-
f"Download failed after {MAX_RETRIES} retries due to {e}"
|
|
325
|
-
) from e
|
|
310
|
+
raise RuntimeError(f"Download failed after {MAX_RETRIES} retries due to {e}") from e
|
|
326
311
|
if verbose:
|
|
327
|
-
print(
|
|
328
|
-
f"{e} on attempt {attempt}/{MAX_RETRIES}, retrying after backoff..."
|
|
329
|
-
)
|
|
312
|
+
print(f"{e} on attempt {attempt}/{MAX_RETRIES}, retrying after backoff...")
|
|
330
313
|
sleep(RETRY_BACKOFF_BASE**attempt)
|
|
331
314
|
|
|
332
315
|
|
|
@@ -366,17 +349,13 @@ def download_file(
|
|
|
366
349
|
return DownloadState.SKIPPED_OK, 0
|
|
367
350
|
|
|
368
351
|
elif verbose:
|
|
369
|
-
tqdm.write(
|
|
370
|
-
styled_string(f"overwriting {path}, hash mismatch", style="yellow")
|
|
371
|
-
)
|
|
352
|
+
tqdm.write(styled_string(f"overwriting {path}, hash mismatch", style="yellow"))
|
|
372
353
|
|
|
373
354
|
elif not overwrite and file.size is not None:
|
|
374
355
|
return DownloadState.SKIPPED_FILE_SIZE_MISMATCH, 0
|
|
375
356
|
|
|
376
357
|
elif verbose:
|
|
377
|
-
tqdm.write(
|
|
378
|
-
styled_string(f"overwriting {path}, file size mismatch", style="yellow")
|
|
379
|
-
)
|
|
358
|
+
tqdm.write(styled_string(f"overwriting {path}, file size mismatch", style="yellow"))
|
|
380
359
|
|
|
381
360
|
# request a download url
|
|
382
361
|
download_url = _get_file_download(client, file.id)
|
|
@@ -406,6 +385,10 @@ def download_file(
|
|
|
406
385
|
|
|
407
386
|
observed_hash = b64_md5(path)
|
|
408
387
|
if file.hash is not None and observed_hash != file.hash:
|
|
388
|
+
print(
|
|
389
|
+
f"HASH MISMATCH: {path} expected={file.hash} observed={observed_hash}",
|
|
390
|
+
file=sys.stderr,
|
|
391
|
+
)
|
|
409
392
|
# Download completed but hash failed
|
|
410
393
|
return (
|
|
411
394
|
DownloadState.DOWNLOADED_INVALID_HASH,
|
|
@@ -422,9 +405,7 @@ UPLOAD_STATE_COLOR = {
|
|
|
422
405
|
}
|
|
423
406
|
|
|
424
407
|
|
|
425
|
-
def _upload_handler(
|
|
426
|
-
future: Future[Tuple[UploadState, int]], path: Path, *, verbose: bool = False
|
|
427
|
-
) -> int:
|
|
408
|
+
def _upload_handler(future: Future[Tuple[UploadState, int]], path: Path, *, verbose: bool = False) -> int:
|
|
428
409
|
"""Returns bytes uploaded successfully."""
|
|
429
410
|
state = UploadState.CANCELED # Default to canceled if exception occurs
|
|
430
411
|
size_bytes = 0
|
|
@@ -433,7 +414,7 @@ def _upload_handler(
|
|
|
433
414
|
except Exception as e:
|
|
434
415
|
logger.error(format_traceback(e))
|
|
435
416
|
if verbose:
|
|
436
|
-
tqdm.write(format_error(
|
|
417
|
+
tqdm.write(format_error("error uploading", e, verbose=verbose))
|
|
437
418
|
else:
|
|
438
419
|
print(f"ERROR: {path.absolute()}: {e}", file=sys.stderr)
|
|
439
420
|
return 0 # Return 0 bytes on error
|
|
@@ -503,11 +484,7 @@ def _download_handler(
|
|
|
503
484
|
elif state not in (DownloadState.DOWNLOADED_OK, DownloadState.SKIPPED_OK):
|
|
504
485
|
print(f"SKIP/FAIL: {path.absolute()} ({state.name})", file=sys.stderr)
|
|
505
486
|
|
|
506
|
-
return (
|
|
507
|
-
size_bytes
|
|
508
|
-
if state in (DownloadState.DOWNLOADED_OK, DownloadState.SKIPPED_OK)
|
|
509
|
-
else 0
|
|
510
|
-
)
|
|
487
|
+
return size_bytes if state in (DownloadState.DOWNLOADED_OK, DownloadState.SKIPPED_OK) else 0
|
|
511
488
|
|
|
512
489
|
|
|
513
490
|
def upload_files(
|
|
@@ -524,7 +501,7 @@ def upload_files(
|
|
|
524
501
|
unit="files",
|
|
525
502
|
desc="Uploading files",
|
|
526
503
|
disable=not verbose,
|
|
527
|
-
leave=
|
|
504
|
+
leave=True,
|
|
528
505
|
) as pbar:
|
|
529
506
|
start = monotonic()
|
|
530
507
|
futures: Dict[Future[Tuple[UploadState, int]], Path] = {}
|
|
@@ -534,9 +511,7 @@ def upload_files(
|
|
|
534
511
|
with ThreadPoolExecutor(max_workers=n_workers) as executor:
|
|
535
512
|
for name, path in files.items():
|
|
536
513
|
if not path.is_file():
|
|
537
|
-
console.print(
|
|
538
|
-
f"[yellow]Skipping non-existent file: {path}[/yellow]"
|
|
539
|
-
)
|
|
514
|
+
console.print(f"[yellow]Skipping non-existent file: {path}[/yellow]")
|
|
540
515
|
pbar.update()
|
|
541
516
|
continue
|
|
542
517
|
|
|
@@ -556,10 +531,7 @@ def upload_files(
|
|
|
556
531
|
if future.exception():
|
|
557
532
|
failed_files += 1
|
|
558
533
|
|
|
559
|
-
if (
|
|
560
|
-
future.exception() is None
|
|
561
|
-
and future.result()[0] == UploadState.EXISTS
|
|
562
|
-
):
|
|
534
|
+
if future.exception() is None and future.result()[0] == UploadState.EXISTS:
|
|
563
535
|
skipped_files += 1
|
|
564
536
|
|
|
565
537
|
path = futures[future]
|
|
@@ -567,24 +539,25 @@ def upload_files(
|
|
|
567
539
|
total_uploaded_bytes += uploaded_bytes
|
|
568
540
|
pbar.update()
|
|
569
541
|
|
|
570
|
-
|
|
571
|
-
|
|
542
|
+
end = monotonic()
|
|
543
|
+
elapsed_time = end - start
|
|
572
544
|
|
|
573
|
-
|
|
545
|
+
avg_speed_bps = total_uploaded_bytes / elapsed_time if elapsed_time > 0 else 0
|
|
574
546
|
|
|
547
|
+
if verbose:
|
|
548
|
+
console.print()
|
|
575
549
|
console.print(f"Upload took {elapsed_time:.2f} seconds")
|
|
576
550
|
console.print(f"Total uploaded: {format_bytes(total_uploaded_bytes)}")
|
|
577
551
|
console.print(f"Average speed: {format_bytes(avg_speed_bps, speed=True)}")
|
|
578
552
|
|
|
579
553
|
if failed_files > 0:
|
|
580
554
|
console.print(
|
|
581
|
-
f"\nUploaded {len(files) - failed_files - skipped_files} files,
|
|
555
|
+
f"\nUploaded {len(files) - failed_files - skipped_files} files, "
|
|
556
|
+
f"{skipped_files} skipped, {failed_files} uploads failed",
|
|
582
557
|
style="red",
|
|
583
558
|
)
|
|
584
559
|
else:
|
|
585
|
-
console.print(
|
|
586
|
-
f"\nUploaded {len(files) - skipped_files} files, {skipped_files} skipped"
|
|
587
|
-
)
|
|
560
|
+
console.print(f"\nUploaded {len(files) - skipped_files} files, {skipped_files} skipped")
|
|
588
561
|
|
|
589
562
|
|
|
590
563
|
def download_files(
|
|
@@ -601,7 +574,7 @@ def download_files(
|
|
|
601
574
|
unit="files",
|
|
602
575
|
desc="Downloading files",
|
|
603
576
|
disable=not verbose,
|
|
604
|
-
leave=
|
|
577
|
+
leave=True,
|
|
605
578
|
) as pbar:
|
|
606
579
|
|
|
607
580
|
start = monotonic()
|
|
@@ -621,18 +594,15 @@ def download_files(
|
|
|
621
594
|
total_downloaded_bytes = 0
|
|
622
595
|
for future in as_completed(futures):
|
|
623
596
|
file, path = futures[future]
|
|
624
|
-
downloaded_bytes = _download_handler(
|
|
625
|
-
future, file, path, verbose=verbose
|
|
626
|
-
)
|
|
597
|
+
downloaded_bytes = _download_handler(future, file, path, verbose=verbose)
|
|
627
598
|
total_downloaded_bytes += downloaded_bytes
|
|
628
599
|
pbar.update()
|
|
629
600
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
601
|
+
end = monotonic()
|
|
602
|
+
elapsed_time = end - start
|
|
603
|
+
avg_speed_bps = total_downloaded_bytes / elapsed_time if elapsed_time > 0 else 0
|
|
633
604
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
console.print(f"Average speed: {format_bytes(avg_speed_bps, speed=True)}")
|
|
605
|
+
console.print()
|
|
606
|
+
console.print(f"Download took {elapsed_time:.2f} seconds")
|
|
607
|
+
console.print(f"Total downloaded/verified: {format_bytes(total_downloaded_bytes)}")
|
|
608
|
+
console.print(f"Average speed: {format_bytes(avg_speed_bps, speed=True)}")
|
kleinkram/api/pagination.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from enum import Enum
|
|
4
3
|
from typing import Any
|
|
5
4
|
from typing import Dict
|
|
6
5
|
from typing import Generator
|
|
@@ -17,6 +16,7 @@ DataPage = Dict[str, Any]
|
|
|
17
16
|
PAGE_SIZE = 128
|
|
18
17
|
SKIP = "skip"
|
|
19
18
|
TAKE = "take"
|
|
19
|
+
EXACT_MATCH = "exactMatch"
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def paginated_request(
|
|
@@ -25,6 +25,7 @@ def paginated_request(
|
|
|
25
25
|
params: Optional[Mapping[str, Any]] = None,
|
|
26
26
|
max_entries: Optional[int] = None,
|
|
27
27
|
page_size: int = PAGE_SIZE,
|
|
28
|
+
exact_match: bool = False,
|
|
28
29
|
) -> Generator[DataPage, None, None]:
|
|
29
30
|
total_entries_count = 0
|
|
30
31
|
|
|
@@ -32,10 +33,18 @@ def paginated_request(
|
|
|
32
33
|
|
|
33
34
|
params[TAKE] = page_size
|
|
34
35
|
params[SKIP] = 0
|
|
36
|
+
if exact_match:
|
|
37
|
+
params[EXACT_MATCH] = str(exact_match).lower() # pass string rather than bool
|
|
35
38
|
|
|
36
39
|
while True:
|
|
37
40
|
resp = client.get(endpoint, params=params)
|
|
38
|
-
|
|
41
|
+
|
|
42
|
+
# explicitly handle 404 if json contains message
|
|
43
|
+
if resp.status_code == 404 and "message" in resp.json():
|
|
44
|
+
raise ValueError(resp.json()["message"])
|
|
45
|
+
|
|
46
|
+
# raise for other errors
|
|
47
|
+
resp.raise_for_status()
|
|
39
48
|
|
|
40
49
|
paged_data = resp.json()
|
|
41
50
|
data_page = cast(List[DataPage], paged_data["data"])
|