Skip to content

Commit 68c4c42

Browse files
authored
Merge pull request #20 from dataquest-dev/fix/python-correctness-sweep
fix(client): Python correctness sweep - logging, timeouts, file handle, None guards
2 parents dbabf5d + e9eef06 commit 68c4c42

5 files changed

Lines changed: 126 additions & 61 deletions

File tree

dspace_rest_client/client.py

Lines changed: 53 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,13 @@
2626

2727
__all__ = ['DSpaceClient']
2828

29-
logging.basicConfig(format='%(asctime)s - %(message)s', level=logging.INFO)
3029
_logger = logging.getLogger("dspace.client")
30+
# A library must not configure the root logger - that is the consuming
31+
# application's job. Attach a NullHandler (once) so records are dropped unless
32+
# the application opts in to logging - guarded so reloads/re-imports don't
33+
# accumulate duplicate handlers.
34+
if not any(isinstance(h, logging.NullHandler) for h in _logger.handlers):
35+
_logger.addHandler(logging.NullHandler())
3136

3237

3338
def parse_json(response):
@@ -79,6 +84,9 @@ class DSpaceClient:
7984
USER_AGENT = os.environ['USER_AGENT']
8085
verbose = False
8186
ITER_PAGE_SIZE = 20
87+
# Default per-request timeout in seconds so a stalled server cannot hang the
88+
# client forever; override via the `timeout` constructor argument.
89+
DEFAULT_TIMEOUT = 60
8290
PROXY_DICT = dict(http=os.environ["PROXY_URL"],https=os.environ["PROXY_URL"]) if "PROXY_URL" in os.environ else dict()
8391

8492
# Simple enum for patch operation types
@@ -89,7 +97,7 @@ class PatchOperation:
8997
MOVE = 'move'
9098

9199
def __init__(self, api_endpoint=API_ENDPOINT, username=USERNAME, password=PASSWORD, solr_endpoint=SOLR_ENDPOINT,
92-
solr_auth=SOLR_AUTH, fake_user_agent=False, proxies=PROXY_DICT):
100+
solr_auth=SOLR_AUTH, fake_user_agent=False, proxies=PROXY_DICT, timeout=None):
93101
"""
94102
Accept optional API endpoint, username, password arguments using the OS environment variables as defaults
95103
:param api_endpoint: base path to DSpace REST API, eg. http://localhost:8080/server/api
@@ -105,6 +113,7 @@ def __init__(self, api_endpoint=API_ENDPOINT, username=USERNAME, password=PASSWO
105113
self.proxies = proxies
106114
self.solr = None
107115
self._last_err = None
116+
self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
108117
try:
109118
import pysolr
110119
self.solr = pysolr.Solr(url=solr_endpoint, always_commit=True, timeout=300, auth=solr_auth)
@@ -137,7 +146,7 @@ def authenticate(self, retry=False):
137146
# Get and update CSRF token
138147
r = self.session.post(self.LOGIN_URL, data={'user': self.USERNAME, 'password': self.PASSWORD},
139148
headers=self.auth_request_headers,
140-
proxies=self.proxies)
149+
proxies=self.proxies, timeout=self.timeout)
141150
self.update_token(r)
142151

143152
if r.status_code == 403:
@@ -164,7 +173,7 @@ def authenticate(self, retry=False):
164173

165174
# Get and check authentication status
166175
r = self.session.get(f'{self.API_ENDPOINT}/authn/status', headers=self.request_headers,
167-
proxies=self.proxies)
176+
proxies=self.proxies, timeout=self.timeout)
168177
if r.status_code == 200:
169178
r_json = parse_json(r)
170179
if 'authenticated' in r_json and r_json['authenticated'] is True:
@@ -214,7 +223,7 @@ def api_get(self, url, params=None, data=None, headers=None):
214223
if headers is None:
215224
headers = self.request_headers
216225
r = self.session.get(url, params=params, data=data, headers=headers,
217-
proxies=self.proxies)
226+
proxies=self.proxies, timeout=self.timeout)
218227
self.update_token(r)
219228
return r
220229

@@ -230,7 +239,7 @@ def api_post(self, url, params, json, retry=False, timeout=None):
230239
"""
231240
self._last_err = None
232241
r = self.session.post(url, json=json, params=params, headers=self.request_headers,
233-
proxies=self.proxies, timeout=timeout)
242+
proxies=self.proxies, timeout=timeout if timeout is not None else self.timeout)
234243
self.update_token(r)
235244

236245
if r.status_code == 403:
@@ -252,10 +261,10 @@ def api_post(self, url, params, json, retry=False, timeout=None):
252261
r_json = parse_json(r)
253262
if 'message' in (r_json or {}) and 'Authentication is required' in r_json['message']:
254263
if retry:
255-
logging.error(
264+
_logger.error(
256265
'API Post: Already retried... something must be wrong')
257266
else:
258-
logging.debug("API Post: Retrying request with updated CSRF token")
267+
_logger.debug("API Post: Retrying request with updated CSRF token")
259268
# try to authenticate
260269
self.authenticate()
261270
# Try to authenticate and repeat the request 3 times -
@@ -275,7 +284,7 @@ def api_post_uri(self, url, params, uri_list, retry=False):
275284
"""
276285
self._last_err = None
277286
r = self.session.post(url, data=uri_list, params=params, headers=self.list_request_headers,
278-
proxies=self.proxies)
287+
proxies=self.proxies, timeout=self.timeout)
279288
self.update_token(r)
280289

281290
if r.status_code == 403:
@@ -305,7 +314,7 @@ def api_put(self, url, params, json, retry=False):
305314
"""
306315
self._last_err = None
307316
r = self.session.put(url, params=params, json=json, headers=self.request_headers,
308-
proxies=self.proxies)
317+
proxies=self.proxies, timeout=self.timeout)
309318
self.update_token(r)
310319

311320
if r.status_code == 403:
@@ -337,7 +346,7 @@ def api_put_uri(self, url, params, uri_list, retry=False):
337346
"""
338347
self._last_err = None
339348
r = self.session.put(url, params=params, data=uri_list, headers=self.list_request_headers,
340-
proxies=self.proxies)
349+
proxies=self.proxies, timeout=self.timeout)
341350
self.update_token(r)
342351

343352
if r.status_code == 403:
@@ -368,7 +377,7 @@ def api_delete(self, url, params, retry=False):
368377
"""
369378
self._last_err = None
370379
r = self.session.delete(url, params=params, headers=self.request_headers,
371-
proxies=self.proxies)
380+
proxies=self.proxies, timeout=self.timeout)
372381
self.update_token(r)
373382

374383
if r.status_code == 403:
@@ -401,15 +410,15 @@ def api_patch(self, url, operation, path, value, params=None, retry=False):
401410
"""
402411
self._last_err = None
403412
if url is None:
404-
logging.error('Missing required URL argument')
413+
_logger.error('Missing required URL argument')
405414
return None
406415
if path is None:
407-
logging.error('Need valid path eg. /withdrawn or /metadata/dc.title/0/language')
416+
_logger.error('Need valid path eg. /withdrawn or /metadata/dc.title/0/language')
408417
return None
409418
if (operation == self.PatchOperation.ADD or operation == self.PatchOperation.REPLACE
410419
or operation == self.PatchOperation.MOVE) and value is None:
411420
# missing value required for add/replace/move operations
412-
logging.error('Missing required "value" argument for add/replace/move operations')
421+
_logger.error('Missing required "value" argument for add/replace/move operations')
413422
return None
414423

415424
# compile patch data
@@ -426,7 +435,7 @@ def api_patch(self, url, operation, path, value, params=None, retry=False):
426435
# set headers
427436
# perform patch request
428437
r = self.session.patch(url, json=[data], params=params, headers=self.request_headers,
429-
proxies=self.proxies)
438+
proxies=self.proxies, timeout=self.timeout)
430439
self.update_token(r)
431440

432441
if r.status_code == 403:
@@ -635,7 +644,7 @@ def update_dso(self, dso, params=None):
635644
return None
636645
dso_type = type(dso)
637646
if not isinstance(dso, SimpleDSpaceObject):
638-
logging.error('Only SimpleDSpaceObject types (eg Item, Collection, Community) '
647+
_logger.error('Only SimpleDSpaceObject types (eg Item, Collection, Community) '
639648
'are supported by generic update_dso PUT.')
640649
return dso
641650
try:
@@ -682,11 +691,11 @@ def delete_dso(self, dso=None, url=None, params=None):
682691
"""
683692
if dso is None:
684693
if url is None:
685-
logging.error('Need a DSO or a URL to delete')
694+
_logger.error('Need a DSO or a URL to delete')
686695
return None
687696
else:
688697
if not isinstance(dso, SimpleDSpaceObject):
689-
logging.error('Only SimpleDSpaceObject types (eg Item, Collection, Community, EPerson) '
698+
_logger.error('Only SimpleDSpaceObject types (eg Item, Collection, Community, EPerson) '
690699
'are supported by generic update_dso PUT.')
691700
return dso
692701
# Get self URI from HAL links
@@ -844,15 +853,19 @@ def create_bitstream(self, bundle=None, name=None, path=None, mime=None, metadat
844853
if metadata is None:
845854
metadata = {}
846855
url = f'{self.API_ENDPOINT}/core/bundles/{bundle.uuid}/bitstreams'
847-
file = (name, open(path, 'rb'), mime)
848-
files = {'file': file}
849-
properties = {'name': name, 'metadata': metadata, 'bundleName': bundle.name}
850-
payload = {'properties': json.dumps(properties) + ';application/json'}
851-
h = self.session.headers
852-
h.update({'Content-Encoding': 'gzip', 'User-Agent': self.USER_AGENT})
853-
req = Request('POST', url, data=payload, headers=h, files=files)
854-
prepared_req = self.session.prepare_request(req)
855-
r = self.session.send(prepared_req, proxies=self.proxies)
856+
# open the file in a context manager so the handle is always closed,
857+
# even if prepare/send raises (it was previously leaked to the GC).
858+
with open(path, 'rb') as fh:
859+
files = {'file': (name, fh, mime)}
860+
properties = {'name': name, 'metadata': metadata, 'bundleName': bundle.name}
861+
payload = {'properties': json.dumps(properties) + ';application/json'}
862+
# copy the session headers so this request's Content-Encoding does
863+
# not leak onto every subsequent request (and across threads)
864+
h = dict(self.session.headers)
865+
h.update({'Content-Encoding': 'gzip', 'User-Agent': self.USER_AGENT})
866+
req = Request('POST', url, data=payload, headers=h, files=files)
867+
prepared_req = self.session.prepare_request(req)
868+
r = self.session.send(prepared_req, proxies=self.proxies, timeout=self.timeout)
856869
if 'DSPACE-XSRF-TOKEN' in r.headers:
857870
t = r.headers['DSPACE-XSRF-TOKEN']
858871
_logger.debug('Updating token to ' + t)
@@ -1200,7 +1213,7 @@ def create_user(self, user, token=None):
12001213

12011214
def delete_user(self, user):
12021215
if not isinstance(user, User):
1203-
logging.error('Must be a valid user')
1216+
_logger.error('Must be a valid user')
12041217
return None
12051218
return self.delete_dso(user)
12061219

@@ -1430,16 +1443,21 @@ def get_clarinlruallowances_by_bitstream_and_user(self, bitstream_uuid, user_uui
14301443
return None
14311444

14321445

1433-
def create_clarinlruallowances(self, bitstream_uuid):
1446+
def create_clarinlruallowances(self, bitstream_uuid, metadata_payload=None):
14341447
"""
1435-
Create clarinlruallowances for a bitstream for logged user
1436-
by managing user metadata of bitstream.
1448+
Create clarinlruallowances for a bitstream for the logged-in user by
1449+
managing the bitstream's user metadata.
1450+
@param bitstream_uuid: target bitstream UUID
1451+
@param metadata_payload: list of {"metadataKey", "metadataValue"} dicts.
1452+
Required - there is no meaningful default (the
1453+
previous hardcoded "Test" value was leftover
1454+
debug data, not usable for real callers).
14371455
"""
1456+
if not metadata_payload:
1457+
_logger.error('create_clarinlruallowances requires a metadata_payload')
1458+
return False
14381459
url = f'{self.API_ENDPOINT}/core/clarinusermetadata/manage'
14391460
params = {'bitstreamUUID': bitstream_uuid}
1440-
metadata_payload = [
1441-
{"metadataKey": "NAME", "metadataValue": "Test"}
1442-
]
14431461
try:
14441462
response = self.api_post(url, json=metadata_payload, params=params)
14451463
if response.status_code == 200:

0 commit comments

Comments
 (0)