Skip to content

Commit 18008a5

Browse files
committed
Download checksummed files
Changes download command to download the originally uploaded files. For some files (TSV and CSV) Dataverse creates a formatted copy of the uploaded file in TSV format. Dataverse defaults to downloading this file which does not match the stored checksum. Changes here download the originally uploaded file when appropriate. Changes here use content-disposition to determine the filename. Fixes #6
1 parent 8ba5caa commit 18008a5

3 files changed

Lines changed: 81 additions & 20 deletions

File tree

src/dva/api.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import re
23
import hashlib
34
from pyDataverse.api import NativeApi, DataAccessApi
45
from pyDataverse.models import Datafile
@@ -18,13 +19,28 @@ def get_files_for_doi(self, doi):
1819
dataset = self._api.get_dataset(doi).json()
1920
return dataset['data']['latestVersion']['files']
2021

21-
def download_file(self, dvfile, path):
22+
def _get_datafile_response(self, dvfile):
23+
dv_data_file = dvfile["dataFile"]
24+
# Retrieve the original file (that matches MD5 checksum) for files processed by Dataverse ingress
25+
data_format = None
26+
if dv_data_file.get("originalFileFormat"):
27+
data_format = "original"
28+
29+
# NOTE: the call below blocks until the entire file is retrieved (in memory)
30+
return self._data_api.get_datafile(dv_data_file["id"], data_format=data_format)
31+
32+
def download_file(self, dvfile, dest):
33+
response = self._get_datafile_response(dvfile)
34+
filename = self.get_download_filename(response)
35+
directory_label = dvfile.get("directoryLabel", "")
36+
if directory_label:
37+
path = os.path.join(dest, directory_label, filename)
38+
else:
39+
path = os.path.join(dest, filename)
2240
os.makedirs(os.path.dirname(path), exist_ok=True)
23-
file_id = dvfile["dataFile"]["id"]
2441
with open(path, "wb") as f:
25-
# NOTE: the call below blocks until the entire file is retrieved (in memory)
26-
response = self._data_api.get_datafile(file_id)
2742
f.write(response.content)
43+
return path
2844

2945
@staticmethod
3046
def verify_checksum(dvfile, path):
@@ -51,21 +67,28 @@ def upload_file(self, doi, path, dirname=""):
5167
raise APIException(f"Uploading failed with status {status}.")
5268

5369
@staticmethod
54-
def get_dvfile_path(dvfile, parent_dir=None):
70+
def get_remote_path(dvfile):
5571
path = dvfile["dataFile"]["filename"]
5672
directory_label = dvfile.get("directoryLabel", "")
5773
if directory_label:
5874
path = f"{directory_label}/{path}"
59-
if parent_dir:
60-
path = f"{parent_dir}/{path}"
6175
return path
6276

77+
@staticmethod
78+
def get_download_filename(response):
79+
content_disposition = response.headers['Content-disposition']
80+
regex = '^ *filename=(.*?)$'
81+
for part in content_disposition.split(';'):
82+
found_items = re.findall(regex, part)
83+
if found_items:
84+
return found_items[0].strip('"')
85+
raise APIException(f"Invalid Content-disposition {content_disposition}")
86+
6387
@staticmethod
6488
def get_dvfile_size(dvfile):
6589
return dvfile["dataFile"]["filesize"]
6690

6791

68-
6992
def get_api(url):
7093
config = Config(url)
7194
return API(

src/dva/cli.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ def ls(doi, json_format, url):
2727
click.echo(json.dumps(dvfiles, indent=4))
2828
else:
2929
for dvfile in dvfiles:
30-
path = api.get_dvfile_path(dvfile)
31-
click.echo(path)
30+
remote_path = api.get_remote_path(dvfile)
31+
click.echo(remote_path)
3232

3333

3434
@click.command()
@@ -43,11 +43,12 @@ def download(doi, dest, url):
4343
"""
4444
api = get_api(url)
4545
for dvfile in api.get_files_for_doi(doi):
46-
path = api.get_dvfile_path(dvfile, dest)
47-
click.echo(f"Downloading {path}")
48-
api.download_file(dvfile, path)
46+
file_id = dvfile["dataFile"]["id"]
47+
click.echo(f"Downloading file {file_id}")
48+
path = api.download_file(dvfile, dest)
49+
click.echo(f"Downloaded file {file_id} to {path}")
4950
api.verify_checksum(dvfile, path)
50-
click.echo(f"Verified file checksum for {path}.")
51+
click.echo(f"Verified file checksum for {path}")
5152

5253

5354
@click.command()

tests/test_api.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,17 @@ def test_get_files_for_doi(self, mock_data_api, mock_native_api, mock_config):
4141
self.assertEqual(len(result), 1)
4242
self.assertEqual(result[0]["dataFile"]["id"], 2222)
4343

44-
def test_get_dvfile_path(self):
44+
def test_get_remote_path(self):
4545
dvfile = {
4646
"dataFile": {
4747
"filename": "data.txt"
4848
}
4949
}
50-
result = API.get_dvfile_path(dvfile, parent_dir="/tmp")
51-
self.assertEqual(result, "/tmp/data.txt")
50+
result = API.get_remote_path(dvfile)
51+
self.assertEqual(result, "data.txt")
5252
dvfile["directoryLabel"] = "results"
53-
result = API.get_dvfile_path(dvfile, parent_dir="/tmp")
54-
self.assertEqual(result, "/tmp/results/data.txt")
53+
result = API.get_remote_path(dvfile)
54+
self.assertEqual(result, "results/data.txt")
5555

5656
@patch('dva.api.Config')
5757
@patch('dva.api.NativeApi')
@@ -62,9 +62,35 @@ def test_download_file(self, mock_data_api, mock_native_api, mock_config):
6262
"id": 2222
6363
}
6464
}
65+
mock_data_api.return_value.get_datafile.return_value = Mock(headers={
66+
'Content-disposition': 'attachment; filename="data.txt"'
67+
})
6568
api = get_api(url=None)
6669
with patch("builtins.open", mock_open()) as mock_file:
67-
api.download_file(dvfile, path="/tmp/data.txt")
70+
api.download_file(dvfile, dest="/tmp")
71+
mock_data_api.return_value.get_datafile.assert_called_with(2222, data_format=None)
72+
mock_file.assert_called_with("/tmp/data.txt", "wb")
73+
mock_file.return_value.write.assert_called_with(
74+
mock_data_api.return_value.get_datafile.return_value.content
75+
)
76+
77+
@patch('dva.api.Config')
78+
@patch('dva.api.NativeApi')
79+
@patch('dva.api.DataAccessApi')
80+
def test_download_file_original(self, mock_data_api, mock_native_api, mock_config):
81+
dvfile = {
82+
"dataFile": {
83+
"id": 2222,
84+
"originalFileFormat": "CSV"
85+
}
86+
}
87+
mock_data_api.return_value.get_datafile.return_value = Mock(headers={
88+
'Content-disposition': 'attachment; filename="data.txt"'
89+
})
90+
api = get_api(url=None)
91+
with patch("builtins.open", mock_open()) as mock_file:
92+
api.download_file(dvfile, dest="/tmp")
93+
mock_data_api.return_value.get_datafile.assert_called_with(2222, data_format='original')
6894
mock_file.assert_called_with("/tmp/data.txt", "wb")
6995
mock_file.return_value.write.assert_called_with(
7096
mock_data_api.return_value.get_datafile.return_value.content
@@ -108,3 +134,14 @@ def test_verify_checksum(self, mock_datafile, mock_data_api, mock_native_api, mo
108134
with patch("builtins.open", mock_open(read_data=b"123")) as mock_file:
109135
api.upload_file(doi='doi:10.70122/FK2/WUU4DM', path="/tmp/data.txt")
110136
self.assertEqual(str(raised_exception.exception), "Uploading failed with status bad.")
137+
138+
def test_get_download_filename(self):
139+
good_values = [
140+
('attachment; filename=bob.txt', 'bob.txt'),
141+
('attachment; filename="tom.txt"', 'tom.txt'),
142+
('attachment; filename="file1.txt"; other="a"', 'file1.txt'),
143+
]
144+
response = Mock()
145+
for content_disposition, expected_result in good_values:
146+
response.headers = {'Content-disposition': content_disposition}
147+
self.assertEqual(expected_result, API.get_download_filename(response))

0 commit comments

Comments
 (0)