• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

HEPData / hepdata-cli / 17372461001

01 Sep 2025 08:44AM UTC coverage: 95.279% (-1.8%) from 97.115%
17372461001

Pull #9

github

web-flow
Merge 5af1d4f8e into f96425ea3
Pull Request #9: Return file paths for downloaded files

32 of 37 new or added lines in 2 files covered. (86.49%)

222 of 233 relevant lines covered (95.28%)

0.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.56
/hepdata_cli/api.py
1
# -*- coding: utf-8 -*-
2

3
from .version import __version__
1✔
4
from .resilient_requests import resilient_requests
1✔
5

6
import tarfile
1✔
7
import sys
1✔
8
import re
1✔
9
import os
1✔
10
import errno
1✔
11

12
SITE_URL = "https://www.hepdata.net"
1✔
13
# SITE_URL = "http://127.0.0.1:5000"
14

15
UPLOAD_MAX_SIZE = 52000000  # Upload limit in bytes
1✔
16
ALLOWED_FORMATS = ['csv', 'root', 'yaml', 'yoda', 'yoda1', 'yoda.h5', 'json']
1✔
17

18
MAX_MATCHES, MATCHES_PER_PAGE = (10000, 10) if "pytest" not in sys.modules else (144, 12)
1✔
19

20

21
class Client(object):
1✔
22
    """API class to handle all queries to HEPData."""
23

24
    def __init__(self, verbose=False):
1✔
25
        """
26
        Initialises the client object.
27

28
        :param verbose: prints additional output.
29
        """
30
        self.verbose = verbose
1✔
31
        self.version = __version__
1✔
32
        # check service availability
33
        resilient_requests('get', SITE_URL + '/ping')
1✔
34

35
    def find(self, query, keyword=None, ids=None, max_matches=MAX_MATCHES, matches_per_page=MATCHES_PER_PAGE):
1✔
36
        """
37
        Search function for the hepdata database. Calls hepdata.net search function.
38

39
        :param query: string passed to hepdata.net search function. See advanced search tips at hepdata.net.
40
        :param keyword: filters return dictionary for given keyword. Exact match is first attempted, otherwise partial match is accepted.
41
        :param ids: accepts one of ("arxiv", "inspire", "hepdata").
42

43
        :return: returns a list of (filtered if 'keyword' is specified) dictionaries for the search matches. If 'ids' is specified it instead returns a list of ids as a string.
44
        """
45
        find_results = []
1✔
46
        for counter in range(int(max_matches / matches_per_page)):
1✔
47
            counter += 1
1✔
48
            response = self._query(query, page=counter, size=matches_per_page)
1✔
49
            data = response.json()
1✔
50
            if len(data['results']) == 0:
1✔
51
                break
1✔
52
            elif keyword is None and ids is None:
1✔
53
                # return full list of dictionary
54
                find_results += data['results']
1✔
55
            else:
56
                assert ids in [None, "arxiv", "inspire", "hepdata", "id"], "allowd ids are: arxiv, inspire and hepdata"
1✔
57
                if ids is not None:
1✔
58
                    if ids == "hepdata":
1✔
59
                        ids = "id"
1✔
60
                    keyword = ids
1✔
61
                # return specific dictionary entry (exact match)
62
                if any([keyword in result.keys() for result in data['results']]):
1✔
63
                    if ids is None:
1✔
64
                        find_results += [{keyword: result[keyword]} for result in data['results'] if keyword in result.keys()]
1✔
65
                    else:
66
                        find_results += [str(result[keyword]).replace("arXiv:", "") for result in data['results'] if keyword in result.keys()]
1✔
67
                # return specific dictionary entry (partial match)
68
                elif any([any([keyword in key for key in result.keys()]) for result in data['results']]):
1✔
69
                    if ids is None:
1✔
70
                        find_results += [{key: result[key] for key in result.keys() if keyword in key} for result in data['results']]
1✔
71
                    else:
72
                        find_results += [[str(result[key]).replace("arXiv:", "") for key in result.keys() if keyword in key][0]
1✔
73
                                         if len([result[key] for key in result.keys() if keyword in key]) > 0 else "" for result in data['results']]
74
            if len(data['results']) < matches_per_page:
1✔
75
                break
1✔
76
        if ids is None:
1✔
77
            return find_results
1✔
78
        else:
79
            return ' '.join(find_results)
1✔
80

81
    def download(self, id_list, file_format=None, ids=None, table_name='', download_dir='./hepdata-downloads'):
1✔
82
        """
83
        Downloads from the hepdata database the specified records.
84

85
        :param id_list: list of ids to download. These can be obtained by the find function.
86
        :param file_format: accepts one of ('csv', 'root', 'yaml', 'yoda', 'yoda1', 'yoda.h5', 'json'). Specifies the download file format.
87
        :param ids: accepts one of ('inspire', 'hepdata'). It specifies what type of ids have been passed.
88
        :param table_name: restricts download to specific tables.
89
        :param download_dir: defaults to ./hepdata-downloads. Specifies where to download the files.
90

91
        :return: dictionary mapping id to list of downloaded files.
92
        :rtype: dict[int, list[str]]
93
        """
94

95
        url_map = self._build_urls(id_list, file_format, ids, table_name)
1✔
96
        file_map = {}
1✔
97
        for record_id, url in url_map.items():
1✔
98
            if self.verbose is True:
1✔
99
                print("Downloading: " + url)
1✔
100
            files_downloaded = download_url(url, download_dir)
1✔
101
            file_map[record_id] = files_downloaded
1✔
102
        return file_map
1✔
103

104
    def fetch_names(self, id_list, ids=None):
1✔
105
        """
106
        Returns the names of the tables in the provided records. These are the possible inputs of table_name parameter in download function.
107

108
        :param id_list: list of id of records of which to return table names.
109
        :param ids: accepts one of ('inspire', 'hepdata'). It specifies what type of ids have been passed.
110
        """
111
        url_map = self._build_urls(id_list, 'json', ids, '')
1✔
112
        table_names = []
1✔
113
        for url in url_map.values():
1✔
114
            response = resilient_requests('get', url)
1✔
115
            json_dict = response.json()
1✔
116
            table_names += [[data_table['name'] for data_table in json_dict['data_tables']]]
1✔
117
        return table_names
1✔
118

119
    def upload(self, path_to_file, email, recid=None, invitation_cookie=None, sandbox=True, password=None):
1✔
120
        """
121
        Upload record.
122

123
        :param path_to_file: path of file to be uploaded.
124
        :param email: email address of existing HEPData user.
125
        :recid: HEPData ID (not the INSPIRE ID) of an existing record.
126
        :invitation_cookie: token sent in the invitation email for a non-sandbox record.
127
        :sandbox: True (default) or False if the file should be uploaded to the sandbox.
128
        :password: password of existing HEPData user (prompt if not specified).
129
        """
130
        file_size = os.path.getsize(path_to_file)
1✔
131
        assert file_size < UPLOAD_MAX_SIZE,\
1✔
132
            '{} too large ({} bytes > {} bytes)'.format(path_to_file, file_size, UPLOAD_MAX_SIZE)
133
        files = {'hep_archive': open(path_to_file, 'rb')}
1✔
134
        data = {'email': email, 'recid': recid, 'invitation_cookie': invitation_cookie, 'sandbox': sandbox, 'pswd': password}
1✔
135
        resilient_requests('post', SITE_URL + '/record/cli_upload', data=data, files=files)
1✔
136
        # print upload location
137
        if sandbox is True and recid is None:
1✔
138
            print('Uploaded ' + path_to_file + ' to a new record at ' + SITE_URL + '/record/sandbox')
1✔
139
        elif sandbox is True and recid is not None:
1✔
140
            print('Uploaded ' + path_to_file + ' to ' + SITE_URL + '/record/sandbox/' + str(recid))
1✔
141
        else:
142
            print('Uploaded ' + path_to_file + ' to ' + SITE_URL + '/record/' + str(recid))
1✔
143

144
    def _build_urls(self, id_list, file_format, ids, table_name):
1✔
145
        """
146
        Builds urls for download and fetch_names, given the specified parameters.
147
        
148
        :param id_list: list of ids to download.
149
        :param file_format: accepts one of ('csv', 'root', 'yaml', 'yoda', 'yoda1', 'yoda.h5', 'json').
150
        :param ids: accepts one of ('inspire', 'hepdata').
151
        :param table_name: restricts download to specific tables.
152
        
153
        :return: dictionary mapping id to url.
154
        """
155
        if type(id_list) not in (tuple, list):
1✔
156
            id_list = id_list.split()
1✔
157
        assert len(id_list) > 0, 'Ids are required.'
1✔
158
        assert file_format in ALLOWED_FORMATS, f"allowed formats are: {ALLOWED_FORMATS}"
1✔
159
        assert ids in ['inspire', 'hepdata'], "allowed ids are: inspire and hepdata."
1✔
160
        if table_name == '':
1✔
161
            params = {'format': file_format}
1✔
162
        else:
163
            params = {'format': file_format, 'table': table_name}
1✔
164
        url_mapping = {}
1✔
165
        for id_entry in id_list:
1✔
166
            url = resilient_requests('get', SITE_URL + '/record/' + ('ins' if ids == 'inspire' else '') + id_entry, params=params).url.replace('%2525', '%25')
1✔
167
            url_mapping[id_entry] = url
1✔
168
        # TODO: Investigate root cause of double URL encoding (https://github.com/HEPData/hepdata-cli/issues/8).
169
        return url_mapping
1✔
170

171
    def _query(self, query, page, size):
1✔
172
        """Builds the search query passed to hepdata.net."""
173
        url = SITE_URL + '/search/?q=' + query + '&format=json&page=' + str(page) + '&size=' + str(size)
1✔
174
        response = resilient_requests('get', url)
1✔
175
        if self.verbose is True:
1✔
176
            print('Looking up: ' + url)
1✔
177
        return response
1✔
178

179

180
def mkdir(directory):
1✔
181
    if not os.path.exists(directory):
1✔
182
        try:
1✔
183
            os.makedirs(directory)
1✔
184
        except OSError as exc:   # Guard against race condition (directory created between os.path.exists and os.makedirs)
×
185
            if exc.errno != errno.EEXIST:
×
186
                raise Exception
×
187

188

189
def download_url(url, download_dir):
1✔
190
    """Download file and if necessary extract it."""
191
    files_downloaded = []
1✔
192
    assert is_downloadable(url), "Given url is not downloadable: {}".format(url)
1✔
193
    response = resilient_requests('get', url, allow_redirects=True)
1✔
194
    if url[-4:] == 'json':
1✔
195
        filename = 'HEPData-' + url.split('/')[-1].split("?")[0] + ".json"
1✔
196
    else:
197
        filename = getFilename_fromCd(response.headers.get('content-disposition'))
1✔
198
    if filename[0] == '"' and filename[-1] == '"':
1✔
199
        filename = filename[1:-1]
1✔
200
    filepath = download_dir + "/" + filename
1✔
201
    mkdir(os.path.dirname(filepath))
1✔
202
    open(filepath, 'wb').write(response.content)
1✔
203
    if filepath.endswith("tar.gz") or filepath.endswith("tar"):
1✔
204
        tar = None
1✔
205
        try:
1✔
206
            tar = tarfile.open(filepath, "r:gz" if filepath.endswith("tar.gz") else "r:")
1✔
207
            extract_dir = os.path.abspath(os.path.dirname(filepath))
1✔
208
            tar.extractall(path=os.path.dirname(filepath))
1✔
209
            for member in tar.getmembers():
1✔
210
                if member.isfile():
1✔
211
                    extracted_path = os.path.join(os.path.dirname(filepath), member.name)
1✔
212
                    abs_extracted_path = os.path.abspath(extracted_path)
1✔
213
                    if abs_extracted_path.startswith(extract_dir + os.sep) and os.path.exists(abs_extracted_path):
1✔
214
                        files_downloaded.append(abs_extracted_path)
1✔
NEW
215
                    elif not abs_extracted_path.startswith(extract_dir + os.sep):
×
NEW
216
                        raise ValueError(f"Attempted path traversal for file {member.name}")
×
217
                    else:
NEW
218
                        raise FileNotFoundError(f"Extracted file {member.name} not found")
×
NEW
219
        except Exception as e:
×
NEW
220
            raise Exception(f"Failed to extract {filepath}: {str(e)}")
×
221
        finally:
222
            if tar:
1✔
223
                tar.close()
1✔
224
            if os.path.exists(filepath):
1✔
225
                os.remove(filepath)
1✔
226
    else:
227
        files_downloaded.append(filepath)
1✔
228
    return files_downloaded
1✔
229

230

231
def getFilename_fromCd(cd):
1✔
232
    """Get filename from content-disposition."""
233
    if not cd:
1✔
234
        return None
1✔
235
    fname = re.findall('filename=(.+)', cd)
1✔
236
    if len(fname) == 0:
1✔
237
        return None
1✔
238
    return fname[0]
1✔
239

240

241
def is_downloadable(url):
1✔
242
    """Does the url contain a downloadable resource?"""
243
    header = resilient_requests('head', url, allow_redirects=True).headers
1✔
244
    content_type = header.get('content-type')
1✔
245
    if 'html' in content_type.lower():
1✔
246
        return False
1✔
247
    return True
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc