kleinkram 0.43.2.dev20250331113255__py3-none-any.whl → 0.43.2.dev20250331124109__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.
@@ -17,18 +17,18 @@ from uuid import UUID
17
17
  import boto3.s3.transfer
18
18
  import botocore.config
19
19
  import httpx
20
- from rich.console import Console
21
- from tqdm import tqdm
22
-
23
20
  from kleinkram.api.client import AuthenticatedClient
24
21
  from kleinkram.config import get_config
25
22
  from kleinkram.errors import AccessDenied
26
23
  from kleinkram.models import File
27
24
  from kleinkram.models import FileState
28
25
  from kleinkram.utils import b64_md5
26
+ from kleinkram.utils import format_bytes
29
27
  from kleinkram.utils import format_error
30
28
  from kleinkram.utils import format_traceback
31
29
  from kleinkram.utils import styled_string
30
+ from rich.console import Console
31
+ from tqdm import tqdm
32
32
 
33
33
  logger = logging.getLogger(__name__)
34
34
 
@@ -160,9 +160,9 @@ def upload_file(
160
160
  path: Path,
161
161
  verbose: bool = False,
162
162
  s3_endpoint: Optional[str] = None,
163
- ) -> UploadState:
163
+ ) -> Tuple[UploadState, int]:
164
164
  """\
165
- returns bytes uploaded
165
+ returns UploadState and bytes uploaded (0 if not uploaded)
166
166
  """
167
167
  if s3_endpoint is None:
168
168
  s3_endpoint = get_config().endpoint.s3
@@ -181,17 +181,20 @@ def upload_file(
181
181
  client, internal_filename=filename, mission_id=mission_id
182
182
  )
183
183
  if creds is None:
184
- return UploadState.EXISTS
184
+ return UploadState.EXISTS, 0
185
185
 
186
186
  try:
187
187
  _s3_upload(path, endpoint=s3_endpoint, credentials=creds, pbar=pbar)
188
188
  except Exception as e:
189
189
  logger.error(format_traceback(e))
190
- _cancel_file_upload(client, creds.file_id, mission_id)
191
- return UploadState.CANCELED
190
+ try:
191
+ _cancel_file_upload(client, creds.file_id, mission_id)
192
+ except Exception as cancel_e:
193
+ logger.error(f"Failed to cancel upload for {creds.file_id}: {cancel_e}")
194
+ raise e from e
192
195
  else:
193
196
  _confirm_file_upload(client, creds.file_id, b64_md5(path))
194
- return UploadState.UPLOADED
197
+ return UploadState.UPLOADED, total_size
195
198
 
196
199
 
197
200
  def _get_file_download(client: AuthenticatedClient, id: UUID) -> str:
@@ -203,7 +206,7 @@ def _get_file_download(client: AuthenticatedClient, id: UUID) -> str:
203
206
  if 400 <= resp.status_code < 500:
204
207
  raise AccessDenied(
205
208
  f"Failed to download file: {resp.json()['message']}"
206
- f"Status Code: {resp.status_code}",
209
+ f" Status Code: {resp.status_code}",
207
210
  )
208
211
 
209
212
  resp.raise_for_status()
@@ -218,6 +221,7 @@ def _url_download(
218
221
  raise FileExistsError(f"file already exists: {path}")
219
222
 
220
223
  with httpx.stream("GET", url) as response:
224
+ response.raise_for_status()
221
225
  with open(path, "wb") as f:
222
226
  with tqdm(
223
227
  total=size,
@@ -247,12 +251,14 @@ def download_file(
247
251
  path: Path,
248
252
  overwrite: bool = False,
249
253
  verbose: bool = False,
250
- ) -> DownloadState:
254
+ ) -> Tuple[DownloadState, int]:
255
+ """\
256
+ Returns DownloadState and bytes downloaded (file.size if successful or skipped ok, 0 otherwise)
257
+ """
251
258
  # skip files that are not ok on remote
252
259
  if file.state != FileState.OK:
253
- return DownloadState.SKIPPED_INVALID_REMOTE_STATE
260
+ return DownloadState.SKIPPED_INVALID_REMOTE_STATE, 0
254
261
 
255
- # skip existing files depending on flags set
256
262
  if path.exists():
257
263
  local_hash = b64_md5(path)
258
264
  if local_hash != file.hash and not overwrite and file.hash is not None:
@@ -270,17 +276,38 @@ def download_file(
270
276
  # request a download url
271
277
  download_url = _get_file_download(client, file.id)
272
278
 
273
- # create parent directories
274
- path.parent.mkdir(parents=True, exist_ok=True)
279
+ # create parent directories (moved earlier, before file open)
280
+ # path.parent.mkdir(parents=True, exist_ok=True)
275
281
 
276
282
  # download the file and check the hash
277
- _url_download(
278
- download_url, path=path, size=file.size, overwrite=overwrite, verbose=verbose
279
- )
283
+ try:
284
+ _url_download(
285
+ download_url,
286
+ path=path,
287
+ size=file.size,
288
+ overwrite=overwrite,
289
+ verbose=verbose,
290
+ )
291
+ except Exception as e:
292
+ logger.error(f"Error during download of {path}: {e}")
293
+ # Attempt to clean up potentially partial file
294
+ if path.exists():
295
+ try:
296
+ path.unlink()
297
+ logger.info(f"Removed potentially incomplete file {path}")
298
+ except OSError as unlink_e:
299
+ logger.error(f"Could not remove partial file {path}: {unlink_e}")
300
+ raise e # Re-raise to be caught by handler
301
+
280
302
  observed_hash = b64_md5(path)
281
303
  if file.hash is not None and observed_hash != file.hash:
282
- return DownloadState.DOWNLOADED_INVALID_HASH
283
- return DownloadState.DOWNLOADED_OK
304
+ # Download completed but hash failed
305
+ return (
306
+ DownloadState.DOWNLOADED_INVALID_HASH,
307
+ 0,
308
+ ) # 0 bytes considered successful transfer
309
+ # Hash matches or no remote hash to check against
310
+ return DownloadState.DOWNLOADED_OK, file.size
284
311
 
285
312
 
286
313
  UPLOAD_STATE_COLOR = {
@@ -291,17 +318,20 @@ UPLOAD_STATE_COLOR = {
291
318
 
292
319
 
293
320
  def _upload_handler(
294
- future: Future[UploadState], path: Path, *, verbose: bool = False
321
+ future: Future[Tuple[UploadState, int]], path: Path, *, verbose: bool = False
295
322
  ) -> int:
323
+ """Returns bytes uploaded successfully."""
324
+ state = UploadState.CANCELED # Default to canceled if exception occurs
325
+ size_bytes = 0
296
326
  try:
297
- state = future.result()
327
+ state, size_bytes = future.result()
298
328
  except Exception as e:
299
329
  logger.error(format_traceback(e))
300
330
  if verbose:
301
- tqdm.write(format_error(f"error uploading {path}", e))
331
+ tqdm.write(format_error(f"error uploading", e, verbose=verbose))
302
332
  else:
303
- print(path.absolute(), file=sys.stderr)
304
- return 0
333
+ print(f"ERROR: {path.absolute()}: {e}", file=sys.stderr)
334
+ return 0 # Return 0 bytes on error
305
335
 
306
336
  if state == UploadState.UPLOADED:
307
337
  msg = f"uploaded {path}"
@@ -312,11 +342,10 @@ def _upload_handler(
312
342
 
313
343
  if verbose:
314
344
  tqdm.write(styled_string(msg, style=UPLOAD_STATE_COLOR[state]))
315
- else:
316
- stream = sys.stdout if state == UploadState.UPLOADED else sys.stderr
317
- print(path.absolute(), file=stream)
345
+ elif state != UploadState.UPLOADED:
346
+ print(f"SKIP/CANCEL: {path.absolute()}", file=sys.stderr)
318
347
 
319
- return path.stat().st_size if state == UploadState.UPLOADED else 0
348
+ return size_bytes
320
349
 
321
350
 
322
351
  DOWNLOAD_STATE_COLOR = {
@@ -329,41 +358,48 @@ DOWNLOAD_STATE_COLOR = {
329
358
 
330
359
 
331
360
  def _download_handler(
332
- future: Future[DownloadState], file: File, path: Path, *, verbose: bool = False
361
+ future: Future[Tuple[DownloadState, int]],
362
+ file: File,
363
+ path: Path,
364
+ *,
365
+ verbose: bool = False,
333
366
  ) -> int:
367
+ """Returns bytes downloaded/verified."""
368
+ state = DownloadState.DOWNLOADED_INVALID_HASH
369
+ size_bytes = 0
334
370
  try:
335
- state = future.result()
371
+ state, size_bytes = future.result()
336
372
  except Exception as e:
337
373
  logger.error(format_traceback(e))
338
374
  if verbose:
339
- tqdm.write(format_error(f"error uploading {path}", e))
375
+ tqdm.write(format_error(f"error downloading {path}", e))
340
376
  else:
341
- print(path.absolute(), file=sys.stderr)
377
+ print(f"ERROR: {path.absolute()}: {e}", file=sys.stderr)
342
378
  return 0
343
379
 
344
380
  if state == DownloadState.DOWNLOADED_OK:
345
381
  msg = f"downloaded {path}"
346
382
  elif state == DownloadState.DOWNLOADED_INVALID_HASH:
347
- msg = f"downloaded {path} failed hash check"
383
+ msg = f"downloaded {path} but failed hash check"
348
384
  elif state == DownloadState.SKIPPED_OK:
349
- msg = f"skipped {path} already downloaded"
385
+ msg = f"skipped {path} already downloaded (hash ok)"
350
386
  elif state == DownloadState.SKIPPED_INVALID_HASH:
351
- msg = f"skipped {path} already downloaded, hash missmatch, cosider using `--overwrite`"
387
+ msg = f"skipped {path}, exists with hash mismatch (use --overwrite?)"
388
+ elif state == DownloadState.SKIPPED_INVALID_REMOTE_STATE:
389
+ msg = f"skipped {path}, remote file has invalid state ({file.state.value})"
352
390
  else:
353
- msg = f"skipped {path} remote file has invalid state"
391
+ msg = f"skipped {path} with unknown state {state}"
354
392
 
355
393
  if verbose:
356
- tqdm.write(styled_string(msg, style=DOWNLOAD_STATE_COLOR[state]))
357
- else:
358
- stream = (
359
- sys.stdout
360
- if state in (DownloadState.DOWNLOADED_OK, DownloadState.SKIPPED_OK)
361
- else sys.stderr
362
- )
363
- print(path.absolute(), file=stream)
364
-
365
- # number of bytes downloaded
366
- return file.size if state == DownloadState.DOWNLOADED_OK else 0
394
+ tqdm.write(styled_string(msg, style=DOWNLOAD_STATE_COLOR.get(state, "red")))
395
+ elif state not in (DownloadState.DOWNLOADED_OK, DownloadState.SKIPPED_OK):
396
+ print(f"SKIP/FAIL: {path.absolute()} ({state.name})", file=sys.stderr)
397
+
398
+ return (
399
+ size_bytes
400
+ if state in (DownloadState.DOWNLOADED_OK, DownloadState.SKIPPED_OK)
401
+ else 0
402
+ )
367
403
 
368
404
 
369
405
  def upload_files(
@@ -374,17 +410,25 @@ def upload_files(
374
410
  verbose: bool = False,
375
411
  n_workers: int = 2,
376
412
  ) -> None:
413
+ console = Console(file=sys.stderr)
377
414
  with tqdm(
378
415
  total=len(files),
379
416
  unit="files",
380
- desc="uploading files",
417
+ desc="Uploading files",
381
418
  disable=not verbose,
382
419
  leave=False,
383
420
  ) as pbar:
384
421
  start = monotonic()
385
- futures: Dict[Future[UploadState], Path] = {}
422
+ futures: Dict[Future[Tuple[UploadState, int]], Path] = {}
386
423
  with ThreadPoolExecutor(max_workers=n_workers) as executor:
387
424
  for name, path in files.items():
425
+ if not path.is_file():
426
+ console.print(
427
+ f"[yellow]Skipping non-existent file: {path}[/yellow]"
428
+ )
429
+ pbar.update()
430
+ continue
431
+
388
432
  future = executor.submit(
389
433
  upload_file,
390
434
  client=client,
@@ -395,19 +439,21 @@ def upload_files(
395
439
  )
396
440
  futures[future] = path
397
441
 
398
- total_size = 0
442
+ total_uploaded_bytes = 0
399
443
  for future in as_completed(futures):
400
- size = _upload_handler(future, futures[future], verbose=verbose)
401
- total_size += size / 1024 / 1024
402
-
444
+ path = futures[future]
445
+ uploaded_bytes = _upload_handler(future, path, verbose=verbose)
446
+ total_uploaded_bytes += uploaded_bytes
403
447
  pbar.update()
404
- pbar.refresh()
405
448
 
406
- t = monotonic() - start
407
- c = Console(file=sys.stderr)
408
- c.print(f"upload took {t:.2f} seconds")
409
- c.print(f"total size: {int(total_size)} MB")
410
- c.print(f"average speed: {total_size / t:.2f} MB/s")
449
+ end = monotonic()
450
+ elapsed_time = end - start
451
+
452
+ avg_speed_bps = total_uploaded_bytes / elapsed_time if elapsed_time > 0 else 0
453
+
454
+ console.print(f"Upload took {elapsed_time:.2f} seconds")
455
+ console.print(f"Total uploaded: {format_bytes(total_uploaded_bytes)}")
456
+ console.print(f"Average speed: {format_bytes(avg_speed_bps, speed=True)}")
411
457
 
412
458
 
413
459
  def download_files(
@@ -418,16 +464,17 @@ def download_files(
418
464
  overwrite: bool = False,
419
465
  n_workers: int = 2,
420
466
  ) -> None:
467
+ console = Console(file=sys.stderr)
421
468
  with tqdm(
422
469
  total=len(files),
423
470
  unit="files",
424
- desc="downloading files",
471
+ desc="Downloading files",
425
472
  disable=not verbose,
426
473
  leave=False,
427
474
  ) as pbar:
428
475
 
429
476
  start = monotonic()
430
- futures: Dict[Future[DownloadState], Tuple[File, Path]] = {}
477
+ futures: Dict[Future[Tuple[DownloadState, int]], Tuple[File, Path]] = {}
431
478
  with ThreadPoolExecutor(max_workers=n_workers) as executor:
432
479
  for path, file in files.items():
433
480
  future = executor.submit(
@@ -440,16 +487,21 @@ def download_files(
440
487
  )
441
488
  futures[future] = (file, path)
442
489
 
443
- total_size = 0
490
+ total_downloaded_bytes = 0
444
491
  for future in as_completed(futures):
445
492
  file, path = futures[future]
446
- size = _download_handler(future, file, path, verbose=verbose)
447
- total_size += size / 1024 / 1024 # MB
493
+ downloaded_bytes = _download_handler(
494
+ future, file, path, verbose=verbose
495
+ )
496
+ total_downloaded_bytes += downloaded_bytes
448
497
  pbar.update()
449
- pbar.refresh()
450
498
 
451
- time = monotonic() - start
452
- c = Console(file=sys.stderr)
453
- c.print(f"download took {time:.2f} seconds")
454
- c.print(f"total size: {int(total_size)} MB")
455
- c.print(f"average speed: {total_size / time:.2f} MB/s")
499
+ end = monotonic()
500
+ elapsed_time = end - start
501
+ avg_speed_bps = total_downloaded_bytes / elapsed_time if elapsed_time > 0 else 0
502
+
503
+ console.print(f"Download took {elapsed_time:.2f} seconds")
504
+ console.print(
505
+ f"Total downloaded/verified: {format_bytes(total_downloaded_bytes)}"
506
+ )
507
+ console.print(f"Average speed: {format_bytes(avg_speed_bps, speed=True)}")
kleinkram/utils.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import base64
4
4
  import hashlib
5
+ import math
5
6
  import re
6
7
  import string
7
8
  import traceback
@@ -220,3 +221,18 @@ def parse_uuid_like(s: IdLike) -> UUID:
220
221
 
221
222
  def parse_path_like(s: PathLike) -> Path:
222
223
  return Path(s)
224
+
225
+
226
+ def format_bytes(size_bytes: int | float, speed: bool = False) -> str:
227
+ """Formats a size in bytes into a human-readable string with appropriate units."""
228
+ if size_bytes == 0:
229
+ return "0 B/s" if speed else "0 B"
230
+
231
+ units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
232
+ power = math.floor(math.log(size_bytes, 1024))
233
+ unit_index = min(power, len(units) - 1)
234
+
235
+ value = size_bytes / (1024**unit_index)
236
+
237
+ unit_suffix = "/s" if speed else ""
238
+ return f"{value:.2f} {units[unit_index]}{unit_suffix}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kleinkram
3
- Version: 0.43.2.dev20250331113255
3
+ Version: 0.43.2.dev20250331124109
4
4
  Summary: give me your bags
5
5
  Author: Cyrill Püntener, Dominique Garmier, Johann Schwabe
6
6
  Author-email: pucyril@ethz.ch, dgarmier@ethz.ch, jschwab@ethz.ch
@@ -10,12 +10,12 @@ kleinkram/models.py,sha256=8nJlPrKVLSmehspeuQSFV6nUo76JzehUn6KIZYH1xy4,1832
10
10
  kleinkram/printing.py,sha256=_vIjs-lPVgv21ER5q5iYtx44OTs5y8wyd32w27WqocM,12161
11
11
  kleinkram/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  kleinkram/types.py,sha256=nfDjj8TB1Jn5vqO0Xg6qhLOuKom9DDhe62BrngqnVGM,185
13
- kleinkram/utils.py,sha256=fFQsq5isLjDC2Z-XUTiJzz30Wt9rFUi4391WXstusG0,6221
13
+ kleinkram/utils.py,sha256=AsKZxEGStn03L2tqqiMVCQCrDyl8HhwOfpa3no4dfYc,6767
14
14
  kleinkram/wrappers.py,sha256=4xXU43eNnvMG2sssU330MmTLSSRdurOpnZ-zNGOGmt0,11342
15
15
  kleinkram/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  kleinkram/api/client.py,sha256=VwuT97_WdbDpcVGwMXB0fRnUoQnUSf7BOP5eXUFokfI,5932
17
17
  kleinkram/api/deser.py,sha256=xRpYUFKZ0Luoo7XyAtYblJvprmpjNSZOiFVnFKmOzcM,4819
18
- kleinkram/api/file_transfer.py,sha256=3wNlVQdjnRtxOzih5HhCTF18xPbYClFIDxCqbwkLl6c,12985
18
+ kleinkram/api/file_transfer.py,sha256=_uYYJs1iND4YNpg8_-VUo6zu1DuHV8Or2KkGSVAAL0o,15278
19
19
  kleinkram/api/pagination.py,sha256=P_zPsBKlMWkmAv-YfUNHaGW-XLB_4U8BDMrKyiDFIXk,1370
20
20
  kleinkram/api/query.py,sha256=9Exi4hJR7Ml38_zjAcOvSEoIAxZLlpM6QwwzO9fs5Gk,3293
21
21
  kleinkram/api/routes.py,sha256=x0IfzQO5RAC3rohDSpdUNlVOWl221gh4e0fbMKfGrQA,12251
@@ -43,8 +43,8 @@ tests/test_printing.py,sha256=Jz1AjqmqBRjp1JLm6H1oVJyvGaMPlahVXdKnd7UDQFc,2231
43
43
  tests/test_query.py,sha256=fExmCKXLA7-9j2S2sF_sbvRX_2s6Cp3a7OTcqE25q9g,3864
44
44
  tests/test_utils.py,sha256=eUBYrn3xrcgcaxm1X4fqZaX4tRvkbI6rh6BUbNbu9T0,4784
45
45
  tests/test_wrappers.py,sha256=TbcTyO2L7fslbzgfDdcVZkencxNQ8cGPZm_iB6c9d6Q,2673
46
- kleinkram-0.43.2.dev20250331113255.dist-info/METADATA,sha256=yT2DnxVZ6C442aJFxozEUzPJItiF0afhMX2dU4nBsrY,2825
47
- kleinkram-0.43.2.dev20250331113255.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
48
- kleinkram-0.43.2.dev20250331113255.dist-info/entry_points.txt,sha256=SaB2l5aqhSr8gmaMw2kvQU90a8Bnl7PedU8cWYxkfYo,46
49
- kleinkram-0.43.2.dev20250331113255.dist-info/top_level.txt,sha256=N3-sJagEHu1Tk1X6Dx1X1q0pLDNbDZpLzRxVftvepds,24
50
- kleinkram-0.43.2.dev20250331113255.dist-info/RECORD,,
46
+ kleinkram-0.43.2.dev20250331124109.dist-info/METADATA,sha256=oBBV9v06ABIXRmyZQFocEwD5qO1ICqaYSpT2mOBjxas,2825
47
+ kleinkram-0.43.2.dev20250331124109.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
48
+ kleinkram-0.43.2.dev20250331124109.dist-info/entry_points.txt,sha256=SaB2l5aqhSr8gmaMw2kvQU90a8Bnl7PedU8cWYxkfYo,46
49
+ kleinkram-0.43.2.dev20250331124109.dist-info/top_level.txt,sha256=N3-sJagEHu1Tk1X6Dx1X1q0pLDNbDZpLzRxVftvepds,24
50
+ kleinkram-0.43.2.dev20250331124109.dist-info/RECORD,,