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.
- dds_cli-2.15.0/MANIFEST.in +1 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/PKG-INFO +5 -5
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/__init__.py +1 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/base.py +3 -1
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/data_getter.py +92 -24
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/data_putter.py +43 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/version.py +1 -1
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/PKG-INFO +5 -5
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/SOURCES.txt +2 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/requires.txt +4 -4
- dds_cli-2.15.0/requirements.txt +19 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_data_getter.py +233 -24
- {dds_cli-2.14.3 → dds_cli-2.15.0}/LICENSE +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/README.md +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/__main__.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/account_manager.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/auth.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/constants.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/custom_decorators.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/data_lister.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/data_remover.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/directory.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/exceptions.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/file_compressor.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/file_encryptor.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/file_handler.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/file_handler_local.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/file_handler_remote.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/message_helper.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/motd_manager.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/options.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/project_creator.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/project_info.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/project_status.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/s3_connector.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/status.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/superadmin_helper.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/text_handler.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/timestamp.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/unit_manager.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/user.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli/utils.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/dependency_links.txt +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/entry_points.txt +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/not-zip-safe +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/dds_cli.egg-info/top_level.txt +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/pyproject.toml +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/setup.cfg +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/setup.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/__init__.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_account_manager.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_auth.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_base.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_commands.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_data_putter.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_data_remover.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_decorators.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_file_compressor.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_file_encryptor.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_file_handler_local.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_motd_manager.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_project_status.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_s3_connector.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_superadmin_helper.py +0 -0
- {dds_cli-2.14.3 → dds_cli-2.15.0}/tests/test_user.py +0 -0
- {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.
|
|
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==
|
|
19
|
+
Requires-Dist: cryptography==50.0.0
|
|
20
20
|
Requires-Dist: immutabledict==2.2.1
|
|
21
|
-
Requires-Dist: jwcrypto==1.5.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
"
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dds_cli
|
|
3
|
-
Version: 2.
|
|
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==
|
|
19
|
+
Requires-Dist: cryptography==50.0.0
|
|
20
20
|
Requires-Dist: immutabledict==2.2.1
|
|
21
|
-
Requires-Dist: jwcrypto==1.5.
|
|
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.
|
|
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.
|
|
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,20 +1,20 @@
|
|
|
1
1
|
boto3==1.24.73
|
|
2
2
|
botocore==1.27.73
|
|
3
3
|
click==8.1.3
|
|
4
|
-
cryptography==
|
|
4
|
+
cryptography==50.0.0
|
|
5
5
|
immutabledict==2.2.1
|
|
6
|
-
jwcrypto==1.5.
|
|
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.
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|