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/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 = {} # TODO: this crap is really bad to parse
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
+ )
@@ -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, sleep
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 = "/queue/confirmUpload"
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
- resp = client.post(UPLOAD_CREDS, json=dct)
101
- resp.raise_for_status()
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.text
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(f"error uploading", e, verbose=verbose))
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=False,
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
- end = monotonic()
571
- elapsed_time = end - start
542
+ end = monotonic()
543
+ elapsed_time = end - start
572
544
 
573
- avg_speed_bps = total_uploaded_bytes / elapsed_time if elapsed_time > 0 else 0
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, {skipped_files} skipped, {failed_files} uploads failed",
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=False,
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
- end = monotonic()
631
- elapsed_time = end - start
632
- avg_speed_bps = total_downloaded_bytes / elapsed_time if elapsed_time > 0 else 0
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
- console.print(f"Download took {elapsed_time:.2f} seconds")
635
- console.print(
636
- f"Total downloaded/verified: {format_bytes(total_downloaded_bytes)}"
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)}")
@@ -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
- resp.raise_for_status() # TODO: this is fine for now
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"])