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
3338def 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