dds-cli 2.14.3__tar.gz → 2.15.0__tar.gz

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 (66) hide show
  1. dds_cli-2.15.0/MANIFEST.in +1 -0
  2. {dds_cli-2.14.3 → dds_cli-2.15.0}/PKG-INFO +5 -5
  3. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/__init__.py +1 -0
  4. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/base.py +3 -1
  5. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/data_getter.py +92 -24
  6. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/data_putter.py +43 -0
  7. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/version.py +1 -1
  8. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/PKG-INFO +5 -5
  9. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/SOURCES.txt +2 -0
  10. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/requires.txt +4 -4
  11. dds_cli-2.15.0/requirements.txt +19 -0
  12. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_data_getter.py +233 -24
  13. {dds_cli-2.14.3 → dds_cli-2.15.0}/LICENSE +0 -0
  14. {dds_cli-2.14.3 → dds_cli-2.15.0}/README.md +0 -0
  15. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/__main__.py +0 -0
  16. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/account_manager.py +0 -0
  17. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/auth.py +0 -0
  18. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/constants.py +0 -0
  19. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/custom_decorators.py +0 -0
  20. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/data_lister.py +0 -0
  21. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/data_remover.py +0 -0
  22. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/directory.py +0 -0
  23. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/exceptions.py +0 -0
  24. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/file_compressor.py +0 -0
  25. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/file_encryptor.py +0 -0
  26. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/file_handler.py +0 -0
  27. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/file_handler_local.py +0 -0
  28. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/file_handler_remote.py +0 -0
  29. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/message_helper.py +0 -0
  30. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/motd_manager.py +0 -0
  31. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/options.py +0 -0
  32. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/project_creator.py +0 -0
  33. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/project_info.py +0 -0
  34. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/project_status.py +0 -0
  35. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/s3_connector.py +0 -0
  36. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/status.py +0 -0
  37. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/superadmin_helper.py +0 -0
  38. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/text_handler.py +0 -0
  39. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/timestamp.py +0 -0
  40. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/unit_manager.py +0 -0
  41. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/user.py +0 -0
  42. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/utils.py +0 -0
  43. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/dependency_links.txt +0 -0
  44. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/entry_points.txt +0 -0
  45. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/not-zip-safe +0 -0
  46. {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/top_level.txt +0 -0
  47. {dds_cli-2.14.3 → dds_cli-2.15.0}/pyproject.toml +0 -0
  48. {dds_cli-2.14.3 → dds_cli-2.15.0}/setup.cfg +0 -0
  49. {dds_cli-2.14.3 → dds_cli-2.15.0}/setup.py +0 -0
  50. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/__init__.py +0 -0
  51. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_account_manager.py +0 -0
  52. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_auth.py +0 -0
  53. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_base.py +0 -0
  54. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_commands.py +0 -0
  55. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_data_putter.py +0 -0
  56. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_data_remover.py +0 -0
  57. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_decorators.py +0 -0
  58. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_file_compressor.py +0 -0
  59. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_file_encryptor.py +0 -0
  60. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_file_handler_local.py +0 -0
  61. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_motd_manager.py +0 -0
  62. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_project_status.py +0 -0
  63. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_s3_connector.py +0 -0
  64. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_superadmin_helper.py +0 -0
  65. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_user.py +0 -0
  66. {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_utils.py +0 -0
@@ -0,0 +1 @@
1
+ include requirements.txt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dds_cli
3
- Version: 2.14.3
3
+ Version: 2.15.0
4
4
  Summary: A command line tool to manage data and projects in the SciLifeLab Data Delivery System.
5
5
  Home-page: https://github.com/ScilifelabDataCentre/dds_cli
6
6
  Author: SciLifeLab Data Centre
@@ -16,20 +16,20 @@ License-File: LICENSE
16
16
  Requires-Dist: boto3==1.24.73
17
17
  Requires-Dist: botocore==1.27.73
18
18
  Requires-Dist: click==8.1.3
19
- Requires-Dist: cryptography==48.0.0
19
+ Requires-Dist: cryptography==50.0.0
20
20
  Requires-Dist: immutabledict==2.2.1
21
- Requires-Dist: jwcrypto==1.5.7
21
+ Requires-Dist: jwcrypto==1.5.8
22
22
  Requires-Dist: prettytable==3.7.0
23
23
  Requires-Dist: prompt-toolkit==3.0.40
24
24
  Requires-Dist: PyNaCl==1.6.2
25
- Requires-Dist: pytz==2026.2
25
+ Requires-Dist: pytz==2026.3.post1
26
26
  Requires-Dist: PyYAML==6.0.2
27
27
  Requires-Dist: questionary==2.1.1
28
28
  Requires-Dist: requests==2.33.0
29
29
  Requires-Dist: rich==15.0.0
30
30
  Requires-Dist: rich-click==1.5.2
31
31
  Requires-Dist: simplejson==4.1.1
32
- Requires-Dist: tzlocal==4.2
32
+ Requires-Dist: tzlocal==4.3.1
33
33
  Requires-Dist: zstandard==0.23.0
34
34
  Requires-Dist: legacy-cgi==2.6.1; python_version >= "3.13"
35
35
  Dynamic: author
@@ -100,6 +100,7 @@ class DDSEndpoint:
100
100
  FILE_ADD_FAILED = BASE_ENDPOINT + "/file/failed/add"
101
101
 
102
102
  # Project specific urls
103
+ PROJ_UPLOAD_COMPLETE = BASE_ENDPOINT + "/proj/upload/complete"
103
104
  PROJ_ACCESS = BASE_ENDPOINT + "/proj/access"
104
105
  PROJ_BUSY_ANY = BASE_ENDPOINT + "/proj/busy/any"
105
106
  PROJ_INFO = BASE_ENDPOINT + "/proj/info"
@@ -108,7 +108,9 @@ class DDSBaseClass:
108
108
 
109
109
  # Exception is not handled
110
110
  if exception_type is not None:
111
- LOG.debug("Exception: %s with value %s", exception_type, exception_value)
111
+ # %r calls repr(), so exception_value is not interpreted as rich markup
112
+ # and cannot trigger a MarkupError during logging.
113
+ LOG.debug("Exception: %r with value %r", exception_type, exception_value)
112
114
  return False
113
115
 
114
116
  return True
@@ -148,29 +148,20 @@ class DataGetter(base.DDSBaseClass):
148
148
 
149
149
  LOG.debug("File '%s' downloaded: %s", file_name_in_db, file_downloaded)
150
150
 
151
- file_size_verified = False
152
-
151
+ # `get()` now verifies the on-disk byte count against `size_stored`
152
+ # internally and only returns True if the file matches. If we reach
153
+ # this branch, the encrypted-size check has already passed.
154
+ # Defensive guard: make the contract explicit so a future refactor of
155
+ # get() that breaks the size guarantee fails loudly here rather than
156
+ # silently proceeding to decryption and surfacing as failed_op="crypto".
153
157
  if file_downloaded:
154
- ## File size verification
155
- expected_size = file_info["size_stored"]
156
158
  actual_size = file_info["path_downloaded"].stat().st_size
157
-
158
- if actual_size == expected_size:
159
- file_size_verified = True
160
- LOG.debug(
161
- "Downloaded file '%s' size matches expected size: %s bytes.",
162
- file_name_in_db,
163
- expected_size,
164
- )
165
- else:
166
- LOG.debug(
167
- "Downloaded file '%s' size mismatch: expected %s bytes, got %s bytes. Not decrypting.",
168
- file_name_in_db,
169
- expected_size,
170
- actual_size,
159
+ expected_size = file_info["size_stored"]
160
+ if actual_size != expected_size:
161
+ raise RuntimeError(
162
+ f"get() returned True but size mismatch for '{file_name_in_db}': "
163
+ f"expected {expected_size} bytes, got {actual_size} bytes"
171
164
  )
172
-
173
- if file_size_verified:
174
165
  db_updated, message = self.update_db(file=file)
175
166
  LOG.debug(
176
167
  "API call: database updated for file '%s': %s",
@@ -239,18 +230,27 @@ class DataGetter(base.DDSBaseClass):
239
230
 
240
231
  @update_status
241
232
  def get(self, file, progress, task):
242
- """Download files from the cloud."""
233
+ """Download files from the cloud and verify the byte count."""
243
234
  downloaded = False
244
235
  error = ""
245
- file_local = self.filehandler.data[file]["path_downloaded"]
246
- file_remote = self.filehandler.data[file]["url"]
247
- file_name_in_db = escape(str(self.filehandler.data[file]["name_in_db"]))
236
+ file_info = self.filehandler.data[file]
237
+ file_local = file_info["path_downloaded"]
238
+ file_remote = file_info["url"]
239
+ # The encrypted size on the bucket — what we expect on disk after the GET.
240
+ # `size_original` is the plaintext size and is checked later, after decryption.
241
+ expected_size = file_info["size_stored"]
242
+ file_name_in_db = escape(str(file_info["name_in_db"]))
248
243
 
244
+ # ChunkedEncodingError is included so that silent stream truncation
245
+ # (raised manually below) hits the same backoff/retry path as a real
246
+ # network error. Without it, a truncated body looked like a successful
247
+ # download to requests/urllib3 and the retry loop never fired.
249
248
  retryable_exceptions = (
250
249
  requests.exceptions.ConnectTimeout,
251
250
  requests.exceptions.ReadTimeout,
252
251
  requests.exceptions.Timeout,
253
252
  requests.exceptions.ConnectionError,
253
+ requests.exceptions.ChunkedEncodingError,
254
254
  )
255
255
 
256
256
  max_retries = constants.DOWNLOAD_MAX_RETRIES
@@ -262,7 +262,13 @@ class DataGetter(base.DDSBaseClass):
262
262
  error = ""
263
263
  if attempt > 1:
264
264
  progress.reset(task, completed=0)
265
+ # Drop the partial file from the previous attempt before retrying.
266
+ # We don't (yet) support Range-based resume, so a fresh GET starts
267
+ # at byte zero; leaving the old bytes around would just confuse
268
+ # the size check below.
269
+ self._remove_partial(file_local, file_name_in_db)
265
270
 
271
+ bytes_written = 0
266
272
  try:
267
273
  with requests.get(
268
274
  file_remote,
@@ -270,10 +276,42 @@ class DataGetter(base.DDSBaseClass):
270
276
  timeout=(constants.CONNECT_TIMEOUT, constants.READ_TIMEOUT),
271
277
  ) as req:
272
278
  req.raise_for_status()
279
+ # Server-advertised body size. AWS S3 sets this on every GET,
280
+ # but S3-compatible backends behind a proxy may use chunked
281
+ # transfer encoding (no Content-Length) or send a malformed
282
+ # value. Treat both as "unknown" and rely on the size_stored
283
+ # check below to catch truncation in that case.
284
+ try:
285
+ content_length = int(req.headers.get("Content-Length", 0))
286
+ except (TypeError, ValueError):
287
+ content_length = 0
273
288
  with file_local.open(mode="wb") as new_file:
274
289
  for chunk in req.iter_content(chunk_size=FileSegment.SEGMENT_SIZE_CIPHER):
275
290
  progress.update(task, advance=len(chunk))
276
291
  new_file.write(chunk)
292
+ bytes_written += len(chunk)
293
+
294
+ # Guard against silent truncation: requests/urllib3 do not
295
+ # validate that bytes received == Content-Length. A clean TCP
296
+ # FIN mid-stream otherwise looks like an EOF and the loop exits
297
+ # successfully. Raise so the retry path catches us.
298
+ if content_length and bytes_written != content_length:
299
+ raise requests.exceptions.ChunkedEncodingError(
300
+ f"Truncated download for '{file_name_in_db}': "
301
+ f"got {bytes_written} of {content_length} bytes"
302
+ )
303
+
304
+ # Cross-check against the size DDS recorded for the encrypted
305
+ # blob. This used to live in download_and_verify() (outside the
306
+ # retry loop), which made truncation unrecoverable in a single
307
+ # run. Doing it here means a mismatch retries like any other
308
+ # transport error.
309
+ actual_size = file_local.stat().st_size
310
+ if actual_size != expected_size:
311
+ raise requests.exceptions.ChunkedEncodingError(
312
+ f"Size mismatch for '{file_name_in_db}': "
313
+ f"expected {expected_size} bytes, got {actual_size} bytes"
314
+ )
277
315
  except (requests.exceptions.HTTPError, *retryable_exceptions) as err:
278
316
  if (
279
317
  isinstance(err, requests.exceptions.HTTPError)
@@ -295,6 +333,11 @@ class DataGetter(base.DDSBaseClass):
295
333
  wait *= backoff_factor
296
334
  else:
297
335
  downloaded = True
336
+ LOG.debug(
337
+ "Downloaded file '%s' size matches expected size: %s bytes.",
338
+ file_name_in_db,
339
+ expected_size,
340
+ )
298
341
  break
299
342
 
300
343
  if not downloaded and error:
@@ -306,9 +349,34 @@ class DataGetter(base.DDSBaseClass):
306
349
  max_retries,
307
350
  error,
308
351
  )
352
+ LOG.warning(
353
+ "Download of '%s' could not be completed after %d attempts. "
354
+ "Please ensure your internet connection is stable and that your "
355
+ "computer does not enter sleep mode during large file transfers.",
356
+ file_name_in_db,
357
+ max_retries,
358
+ )
359
+ # Don't leave a partial blob on disk after we've given up — it just
360
+ # consumes space and risks being mistaken for a complete file.
361
+ self._remove_partial(file_local, file_name_in_db)
309
362
 
310
363
  return downloaded, error
311
364
 
365
+ @staticmethod
366
+ def _remove_partial(file_local, file_name_in_db):
367
+ """Delete a partial download. Best-effort — never raises."""
368
+ try:
369
+ file_local.unlink(missing_ok=True)
370
+ except OSError as unlink_err:
371
+ # File-locking on Windows occasionally blocks unlink while the
372
+ # progress bar still holds the path; we don't want this to mask
373
+ # the real download error, so just log and move on.
374
+ LOG.debug(
375
+ "Could not remove partial download for '%s': %s",
376
+ file_name_in_db,
377
+ unlink_err,
378
+ )
379
+
312
380
  @update_status
313
381
  def update_db(self, file):
314
382
  """Update file info in db."""
@@ -10,6 +10,7 @@ import itertools
10
10
  import json
11
11
  import logging
12
12
  import pathlib
13
+ import threading
13
14
 
14
15
  # Installed
15
16
  import boto3
@@ -187,6 +188,9 @@ def put(
187
188
  else:
188
189
  LOG.debug("Database retry finished.")
189
190
 
191
+ # Single project metadata refresh after batch (date_updated / last_updated_by)
192
+ putter.touch_project_after_upload()
193
+
190
194
 
191
195
  ###############################################################################
192
196
  # CLASSES ########################################################### CLASSES #
@@ -225,6 +229,9 @@ class DataPutter(base.DDSBaseClass):
225
229
  self.overwrite = overwrite
226
230
  self.silent = silent
227
231
  self.filehandler = None
232
+ self._db_add_lock = threading.Lock()
233
+ self.any_successful_db_add = False
234
+ self.retry_added_any_file = False
228
235
 
229
236
  # Only method "put" can use the DataPutter class
230
237
  if self.method != "put":
@@ -456,6 +463,8 @@ class DataPutter(base.DDSBaseClass):
456
463
  )
457
464
  added_to_db, message = (True, response_json)
458
465
  LOG.debug("API call for file '%s: Adding to database'", fileinfo["path_raw"])
466
+ with self._db_add_lock:
467
+ self.any_successful_db_add = True
459
468
  except (
460
469
  dds_cli.exceptions.ApiRequestError,
461
470
  dds_cli.exceptions.ApiResponseError,
@@ -505,6 +514,8 @@ class DataPutter(base.DDSBaseClass):
505
514
  files_added = response.get("files_added")
506
515
 
507
516
  # Get successfully added files
517
+ if files_added:
518
+ self.retry_added_any_file = True
508
519
  if len(files_added) == len(failed):
509
520
  LOG.info(
510
521
  "All successfully uploaded files were successfully added to the database during the retry."
@@ -523,3 +534,35 @@ class DataPutter(base.DDSBaseClass):
523
534
  "message": "Added with 'retry_add_file_db'",
524
535
  }
525
536
  )
537
+
538
+ def touch_project_after_upload(self):
539
+ """Ask API to refresh project ``date_updated`` / ``last_updated_by`` once per batch."""
540
+ if not (self.any_successful_db_add or self.retry_added_any_file):
541
+ return
542
+ try:
543
+ dds_cli.utils.perform_request(
544
+ DDSEndpoint.PROJ_UPLOAD_COMPLETE,
545
+ method="post",
546
+ params={"project": self.project},
547
+ headers=self.token,
548
+ error_message="Failed to update project timestamp after upload",
549
+ )
550
+ except (
551
+ dds_cli.exceptions.ApiRequestError,
552
+ dds_cli.exceptions.ApiResponseError,
553
+ dds_cli.exceptions.DDSCLIException,
554
+ ) as err:
555
+ err_text = str(err)
556
+ if "Response code: 404" in err_text:
557
+ LOG.warning(
558
+ "Upload succeeded, but the project 'Last updated' timestamp could not "
559
+ "be refreshed because the backend does not expose the upload-complete "
560
+ "endpoint (HTTP 404). Uploaded files are unaffected. Update the backend "
561
+ "to match this CLI version if you need the timestamp to update."
562
+ )
563
+ else:
564
+ LOG.warning(
565
+ "Upload succeeded, but the project 'Last updated' timestamp could not "
566
+ "be refreshed. Uploaded files are unaffected. Details: %s",
567
+ err,
568
+ )
@@ -2,4 +2,4 @@
2
2
 
3
3
  # Do not change bump the major version unless absolutely necessary - makes incompatible with API
4
4
  # If mid or minor version reaches 9, continue to 10, 11 etc.
5
- __version__ = "2.14.3"
5
+ __version__ = "2.15.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dds_cli
3
- Version: 2.14.3
3
+ Version: 2.15.0
4
4
  Summary: A command line tool to manage data and projects in the SciLifeLab Data Delivery System.
5
5
  Home-page: https://github.com/ScilifelabDataCentre/dds_cli
6
6
  Author: SciLifeLab Data Centre
@@ -16,20 +16,20 @@ License-File: LICENSE
16
16
  Requires-Dist: boto3==1.24.73
17
17
  Requires-Dist: botocore==1.27.73
18
18
  Requires-Dist: click==8.1.3
19
- Requires-Dist: cryptography==48.0.0
19
+ Requires-Dist: cryptography==50.0.0
20
20
  Requires-Dist: immutabledict==2.2.1
21
- Requires-Dist: jwcrypto==1.5.7
21
+ Requires-Dist: jwcrypto==1.5.8
22
22
  Requires-Dist: prettytable==3.7.0
23
23
  Requires-Dist: prompt-toolkit==3.0.40
24
24
  Requires-Dist: PyNaCl==1.6.2
25
- Requires-Dist: pytz==2026.2
25
+ Requires-Dist: pytz==2026.3.post1
26
26
  Requires-Dist: PyYAML==6.0.2
27
27
  Requires-Dist: questionary==2.1.1
28
28
  Requires-Dist: requests==2.33.0
29
29
  Requires-Dist: rich==15.0.0
30
30
  Requires-Dist: rich-click==1.5.2
31
31
  Requires-Dist: simplejson==4.1.1
32
- Requires-Dist: tzlocal==4.2
32
+ Requires-Dist: tzlocal==4.3.1
33
33
  Requires-Dist: zstandard==0.23.0
34
34
  Requires-Dist: legacy-cgi==2.6.1; python_version >= "3.13"
35
35
  Dynamic: author
@@ -1,6 +1,8 @@
1
1
  LICENSE
2
+ MANIFEST.in
2
3
  README.md
3
4
  pyproject.toml
5
+ requirements.txt
4
6
  setup.py
5
7
  dds_cli/__init__.py
6
8
  dds_cli/__main__.py
@@ -1,20 +1,20 @@
1
1
  boto3==1.24.73
2
2
  botocore==1.27.73
3
3
  click==8.1.3
4
- cryptography==48.0.0
4
+ cryptography==50.0.0
5
5
  immutabledict==2.2.1
6
- jwcrypto==1.5.7
6
+ jwcrypto==1.5.8
7
7
  prettytable==3.7.0
8
8
  prompt-toolkit==3.0.40
9
9
  PyNaCl==1.6.2
10
- pytz==2026.2
10
+ pytz==2026.3.post1
11
11
  PyYAML==6.0.2
12
12
  questionary==2.1.1
13
13
  requests==2.33.0
14
14
  rich==15.0.0
15
15
  rich-click==1.5.2
16
16
  simplejson==4.1.1
17
- tzlocal==4.2
17
+ tzlocal==4.3.1
18
18
  zstandard==0.23.0
19
19
 
20
20
  [:python_version >= "3.13"]
@@ -0,0 +1,19 @@
1
+ boto3==1.24.73
2
+ botocore==1.27.73
3
+ click==8.1.3
4
+ cryptography==50.0.0
5
+ immutabledict==2.2.1
6
+ jwcrypto==1.5.8
7
+ prettytable==3.7.0
8
+ prompt-toolkit==3.0.40
9
+ PyNaCl==1.6.2
10
+ pytz==2026.3.post1
11
+ PyYAML==6.0.2
12
+ questionary==2.1.1
13
+ requests==2.33.0
14
+ rich==15.0.0
15
+ rich-click==1.5.2
16
+ simplejson==4.1.1
17
+ tzlocal==4.3.1
18
+ zstandard==0.23.0
19
+ legacy-cgi==2.6.1 ; python_version >= '3.13'
@@ -2,7 +2,9 @@
2
2
 
3
3
  # IMPORTS ######################################################################
4
4
 
5
+ import logging
5
6
  import pathlib
7
+ import pytest
6
8
  import requests
7
9
  from types import SimpleNamespace
8
10
  from unittest.mock import MagicMock
@@ -14,8 +16,13 @@ from dds_cli import constants
14
16
  # HELPERS ######################################################################
15
17
 
16
18
 
17
- def _prepare_data_getter(file_name, download_path=None):
18
- """Mock a DataGetter instance with a filehandler containing a single file entry."""
19
+ def _prepare_data_getter(file_name, download_path=None, size_stored=4):
20
+ """Mock a DataGetter instance with a filehandler containing a single file entry.
21
+
22
+ `size_stored` defaults to 4 to match the canonical ``b"data"`` body used by most
23
+ tests below; the byte-count check inside ``get()`` only passes when the on-disk
24
+ size equals this value.
25
+ """
19
26
  # Create DataGetter instance without running __init__
20
27
  dg = DataGetter.__new__(DataGetter)
21
28
 
@@ -31,12 +38,29 @@ def _prepare_data_getter(file_name, download_path=None):
31
38
  "path_downloaded": pathlib.Path(download_path or file_name),
32
39
  "url": "https://example.com/file",
33
40
  "name_in_db": file_name,
41
+ "size_stored": size_stored,
34
42
  }
35
43
  }
36
44
  )
37
45
  return dg
38
46
 
39
47
 
48
+ def _ok_response(body=b"data"):
49
+ """Build a mock requests.Response that streams ``body`` as a single chunk.
50
+
51
+ Sets a Content-Length header matching the body length so the truncation
52
+ check inside ``get()`` is satisfied. Tests that want to simulate a
53
+ truncated response should override ``headers.get`` themselves.
54
+ """
55
+ mock_response = MagicMock()
56
+ mock_response.__enter__.return_value = mock_response
57
+ mock_response.__exit__.return_value = False
58
+ mock_response.iter_content.return_value = [body]
59
+ mock_response.raise_for_status.return_value = None
60
+ mock_response.headers.get.return_value = len(body)
61
+ return mock_response
62
+
63
+
40
64
  # TESTS ########################################################################
41
65
 
42
66
 
@@ -54,13 +78,7 @@ def test_get_uses_timeout(monkeypatch, tmp_path):
54
78
 
55
79
  # Create mock objects
56
80
  progress = MagicMock() # needed for get method but doesn't invoke real progress
57
- mock_response = MagicMock()
58
-
59
- # Define the mock return values
60
- mock_response.__enter__.return_value = mock_response
61
- mock_response.__exit__.return_value = False
62
- mock_response.iter_content.return_value = [b"data"] # Simulate content chunks
63
- mock_response.raise_for_status.return_value = None # Used in download to check for HTTP errors
81
+ mock_response = _ok_response()
64
82
 
65
83
  # Mock the requests.get method to return the mock_response
66
84
  mock_get = MagicMock(return_value=mock_response)
@@ -147,11 +165,7 @@ def test_get_retries_on_connection_error(monkeypatch, tmp_path):
147
165
  monkeypatch.setattr(constants, "DOWNLOAD_MAX_RETRIES", 3)
148
166
  monkeypatch.setattr(constants, "DOWNLOAD_INITIAL_WAIT", 0)
149
167
 
150
- mock_response = MagicMock()
151
- mock_response.__enter__.return_value = mock_response
152
- mock_response.__exit__.return_value = False
153
- mock_response.iter_content.return_value = [b"data"]
154
- mock_response.raise_for_status.return_value = None
168
+ mock_response = _ok_response()
155
169
 
156
170
  call_count = 0
157
171
 
@@ -235,11 +249,7 @@ def test_get_retries_on_http_500(monkeypatch, tmp_path):
235
249
  mock_500_response = MagicMock()
236
250
  mock_500_response.status_code = 500
237
251
 
238
- mock_ok_response = MagicMock()
239
- mock_ok_response.__enter__.return_value = mock_ok_response
240
- mock_ok_response.__exit__.return_value = False
241
- mock_ok_response.iter_content.return_value = [b"data"]
242
- mock_ok_response.raise_for_status.return_value = None
252
+ mock_ok_response = _ok_response()
243
253
 
244
254
  call_count = 0
245
255
 
@@ -289,11 +299,7 @@ def test_get_progress_reset_only_on_retry(monkeypatch, tmp_path):
289
299
  monkeypatch.setattr(constants, "DOWNLOAD_MAX_RETRIES", 3)
290
300
  monkeypatch.setattr(constants, "DOWNLOAD_INITIAL_WAIT", 0)
291
301
 
292
- mock_response = MagicMock()
293
- mock_response.__enter__.return_value = mock_response
294
- mock_response.__exit__.return_value = False
295
- mock_response.iter_content.return_value = [b"data"]
296
- mock_response.raise_for_status.return_value = None
302
+ mock_response = _ok_response()
297
303
 
298
304
  call_count = 0
299
305
 
@@ -312,3 +318,206 @@ def test_get_progress_reset_only_on_retry(monkeypatch, tmp_path):
312
318
  DataGetter.get.__wrapped__(getter, file=file_name, progress=progress, task=task)
313
319
 
314
320
  assert progress.reset.call_count == 2
321
+
322
+
323
+ def test_get_detects_truncated_stream_via_content_length(monkeypatch, tmp_path):
324
+ """A clean TCP close with fewer bytes than Content-Length must trigger a retry.
325
+
326
+ Reproduces the failure mode from the May 2026 ngisthlm02415 incident:
327
+ requests/urllib3 happily report a "successful" download even when the body
328
+ is short, so ``get()`` has to validate the byte count itself.
329
+ """
330
+ file_name = "file.bin"
331
+ file_path = tmp_path / file_name
332
+ # Server says 100 bytes, body delivers 4 — mimics a connection drop
333
+ # mid-stream where the FIN arrives before the full payload.
334
+ getter = _prepare_data_getter(file_name=file_name, download_path=file_path, size_stored=100)
335
+
336
+ monkeypatch.setattr(constants, "DOWNLOAD_MAX_RETRIES", 3)
337
+ monkeypatch.setattr(constants, "DOWNLOAD_INITIAL_WAIT", 0)
338
+
339
+ truncated_response = _ok_response(body=b"data")
340
+ truncated_response.headers.get.return_value = 100 # advertised, not delivered
341
+
342
+ monkeypatch.setattr("dds_cli.data_getter.requests.get", lambda *_, **__: truncated_response)
343
+
344
+ downloaded, message = DataGetter.get.__wrapped__(
345
+ getter, file=file_name, progress=MagicMock(), task=1
346
+ )
347
+
348
+ # The retry path engaged on every attempt and ultimately gave up.
349
+ assert downloaded is False
350
+ assert "Truncated download" in message
351
+ assert "attempt 1/3" in message
352
+
353
+
354
+ def test_get_detects_size_mismatch_against_size_stored(monkeypatch, tmp_path):
355
+ """If the body size matches Content-Length but not size_stored, still fail.
356
+
357
+ This catches the case where a misbehaving S3-compatible backend serves a
358
+ Content-Length that disagrees with what DDS recorded — we trust the DDS
359
+ metadata as the source of truth.
360
+ """
361
+ file_name = "file.bin"
362
+ file_path = tmp_path / file_name
363
+ # DDS expects 100 bytes; the proxy reports and delivers 4 — they agree
364
+ # with each other but disagree with the catalogue.
365
+ getter = _prepare_data_getter(file_name=file_name, download_path=file_path, size_stored=100)
366
+
367
+ monkeypatch.setattr(constants, "DOWNLOAD_MAX_RETRIES", 2)
368
+ monkeypatch.setattr(constants, "DOWNLOAD_INITIAL_WAIT", 0)
369
+
370
+ # Content-Length matches the 4-byte body, so the truncation guard passes;
371
+ # the size_stored comparison is the one that has to catch this.
372
+ consistent_short_response = _ok_response(body=b"data") # headers.get returns 4
373
+
374
+ monkeypatch.setattr(
375
+ "dds_cli.data_getter.requests.get", lambda *_, **__: consistent_short_response
376
+ )
377
+
378
+ downloaded, message = DataGetter.get.__wrapped__(
379
+ getter, file=file_name, progress=MagicMock(), task=1
380
+ )
381
+
382
+ assert downloaded is False
383
+ assert "Size mismatch" in message
384
+
385
+
386
+ def test_get_cleans_up_partial_file_on_failure(monkeypatch, tmp_path):
387
+ """After exhausting retries, the truncated blob must not be left on disk."""
388
+ file_name = "file.bin"
389
+ file_path = tmp_path / file_name
390
+ getter = _prepare_data_getter(file_name=file_name, download_path=file_path, size_stored=100)
391
+
392
+ monkeypatch.setattr(constants, "DOWNLOAD_MAX_RETRIES", 2)
393
+ monkeypatch.setattr(constants, "DOWNLOAD_INITIAL_WAIT", 0)
394
+
395
+ truncated_response = _ok_response(body=b"data")
396
+ truncated_response.headers.get.return_value = 100
397
+
398
+ monkeypatch.setattr("dds_cli.data_getter.requests.get", lambda *_, **__: truncated_response)
399
+
400
+ DataGetter.get.__wrapped__(getter, file=file_name, progress=MagicMock(), task=1)
401
+
402
+ # The point: don't leave a useless 4-byte file masquerading as a download.
403
+ assert not file_path.exists()
404
+
405
+
406
+ def test_get_retries_on_truncation_and_eventually_succeeds(monkeypatch, tmp_path):
407
+ """A retry after a truncated attempt should produce a complete file."""
408
+ file_name = "file.bin"
409
+ file_path = tmp_path / file_name
410
+ getter = _prepare_data_getter(file_name=file_name, download_path=file_path, size_stored=4)
411
+
412
+ monkeypatch.setattr(constants, "DOWNLOAD_MAX_RETRIES", 3)
413
+ monkeypatch.setattr(constants, "DOWNLOAD_INITIAL_WAIT", 0)
414
+
415
+ # First attempt: server claims 4 bytes but only delivers 2 — truncation.
416
+ truncated = MagicMock()
417
+ truncated.__enter__.return_value = truncated
418
+ truncated.__exit__.return_value = False
419
+ truncated.iter_content.return_value = [b"da"]
420
+ truncated.raise_for_status.return_value = None
421
+ truncated.headers.get.return_value = 4
422
+
423
+ # Second attempt: clean 4-byte response.
424
+ full = _ok_response(body=b"data")
425
+
426
+ responses = iter([truncated, full])
427
+ monkeypatch.setattr("dds_cli.data_getter.requests.get", lambda *_, **__: next(responses))
428
+
429
+ downloaded, message = DataGetter.get.__wrapped__(
430
+ getter, file=file_name, progress=MagicMock(), task=1
431
+ )
432
+
433
+ assert downloaded is True
434
+ assert message == ""
435
+ assert file_path.read_bytes() == b"data"
436
+
437
+
438
+ def test_get_tolerates_missing_or_malformed_content_length(monkeypatch, tmp_path):
439
+ """Content-Length absent / chunked / garbled must not crash get().
440
+
441
+ AWS S3 always returns a clean integer, but S3-compatible backends
442
+ sometimes use Transfer-Encoding: chunked (no Content-Length header) or
443
+ sit behind a proxy that rewrites headers. In those cases the
444
+ Content-Length truncation guard is skipped and we fall back to the
445
+ size_stored byte-count check, which still catches truncation.
446
+ """
447
+ file_name = "file.bin"
448
+ file_path = tmp_path / file_name
449
+ # body length matches size_stored, so size_stored check passes;
450
+ # this test asserts we don't crash on the int() parse alone.
451
+ getter = _prepare_data_getter(file_name=file_name, download_path=file_path, size_stored=4)
452
+
453
+ monkeypatch.setattr(constants, "DOWNLOAD_MAX_RETRIES", 1)
454
+
455
+ # Each entry is what req.headers.get("Content-Length", 0) might return
456
+ # from a misbehaving server: missing (default 0 substituted), chunked
457
+ # responses surface as None, and garbled values like "abc" come from
458
+ # broken proxies that concatenate or rewrite headers.
459
+ for header_value in (0, None, "abc", "12, 12"):
460
+ response = _ok_response(body=b"data")
461
+ response.headers.get.return_value = header_value
462
+
463
+ monkeypatch.setattr("dds_cli.data_getter.requests.get", lambda *_, **__: response)
464
+
465
+ downloaded, message = DataGetter.get.__wrapped__(
466
+ getter, file=file_name, progress=MagicMock(), task=1
467
+ )
468
+
469
+ assert downloaded is True, f"failed for Content-Length={header_value!r}: {message}"
470
+
471
+
472
+ def test_get_warns_user_about_connection_on_exhausted_retries(monkeypatch, tmp_path, caplog):
473
+ """After all retries fail, get() must emit a user-facing warning suggesting
474
+ the user check their internet connection and sleep settings."""
475
+ file_name = "file.bin"
476
+ file_path = tmp_path / file_name
477
+ getter = _prepare_data_getter(file_name=file_name, download_path=file_path, size_stored=100)
478
+
479
+ monkeypatch.setattr(constants, "DOWNLOAD_MAX_RETRIES", 2)
480
+ monkeypatch.setattr(constants, "DOWNLOAD_INITIAL_WAIT", 0)
481
+
482
+ truncated_response = _ok_response(body=b"data")
483
+ truncated_response.headers.get.return_value = 100
484
+ monkeypatch.setattr("dds_cli.data_getter.requests.get", lambda *_, **__: truncated_response)
485
+
486
+ with caplog.at_level(logging.WARNING, logger="dds_cli.data_getter"):
487
+ DataGetter.get.__wrapped__(getter, file=file_name, progress=MagicMock(), task=1)
488
+
489
+ warning_texts = " ".join(r.getMessage() for r in caplog.records if r.levelno == logging.WARNING)
490
+ assert "internet connection" in warning_texts.lower()
491
+ assert "sleep" in warning_texts.lower()
492
+
493
+
494
+ def test_download_and_verify_raises_on_size_contract_violation(tmp_path):
495
+ """download_and_verify() must raise RuntimeError when get() returns True but
496
+ the on-disk file doesn't match size_stored.
497
+
498
+ This guards against a future get() refactor that silently breaks the size
499
+ guarantee and would otherwise let a truncated blob reach decryption,
500
+ reintroducing the failed_op='crypto' misattribution from the May 2026 incident.
501
+ """
502
+ file_name = "file.bin"
503
+ file_path = tmp_path / file_name
504
+
505
+ # Write a file that is the wrong size (2 bytes; size_stored expects 100).
506
+ file_path.write_bytes(b"xx")
507
+
508
+ getter = _prepare_data_getter(file_name=file_name, download_path=file_path, size_stored=100)
509
+ getter.filehandler.data[file_name]["size_original"] = 80
510
+ getter.silent = False
511
+
512
+ # get() incorrectly claims success despite the on-disk size mismatch.
513
+ getter.get = MagicMock(return_value=(True, ""))
514
+
515
+ progress = MagicMock()
516
+ progress.add_task.return_value = 1
517
+
518
+ # Bypass @verify_proceed and @subpath_required to call the raw function directly.
519
+ with pytest.raises(RuntimeError, match="get\\(\\) returned True but size mismatch"):
520
+ DataGetter.download_and_verify.__wrapped__.__wrapped__(
521
+ getter, file=file_name, progress=progress
522
+ )
523
+ assert message == ""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes