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 so records are dropped unless the
32+ # application opts in to logging.
33+ _logger .addHandler (logging .NullHandler ())
3134
3235
3336def parse_json (response ):
@@ -79,6 +82,9 @@ class DSpaceClient:
7982 USER_AGENT = os .environ ['USER_AGENT' ]
8083 verbose = False
8184 ITER_PAGE_SIZE = 20
85+ # Default per-request timeout in seconds so a stalled server cannot hang the
86+ # client forever; override via the `timeout` constructor argument.
87+ DEFAULT_TIMEOUT = 60
8288 PROXY_DICT = dict (http = os .environ ["PROXY_URL" ],https = os .environ ["PROXY_URL" ]) if "PROXY_URL" in os .environ else dict ()
8389
8490 # Simple enum for patch operation types
@@ -89,7 +95,7 @@ class PatchOperation:
8995 MOVE = 'move'
9096
9197 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 ):
98+ solr_auth = SOLR_AUTH , fake_user_agent = False , proxies = PROXY_DICT , timeout = None ):
9399 """
94100 Accept optional API endpoint, username, password arguments using the OS environment variables as defaults
95101 :param api_endpoint: base path to DSpace REST API, eg. http://localhost:8080/server/api
@@ -105,6 +111,7 @@ def __init__(self, api_endpoint=API_ENDPOINT, username=USERNAME, password=PASSWO
105111 self .proxies = proxies
106112 self .solr = None
107113 self ._last_err = None
114+ self .timeout = timeout if timeout is not None else self .DEFAULT_TIMEOUT
108115 try :
109116 import pysolr
110117 self .solr = pysolr .Solr (url = solr_endpoint , always_commit = True , timeout = 300 , auth = solr_auth )
@@ -137,7 +144,7 @@ def authenticate(self, retry=False):
137144 # Get and update CSRF token
138145 r = self .session .post (self .LOGIN_URL , data = {'user' : self .USERNAME , 'password' : self .PASSWORD },
139146 headers = self .auth_request_headers ,
140- proxies = self .proxies )
147+ proxies = self .proxies , timeout = self . timeout )
141148 self .update_token (r )
142149
143150 if r .status_code == 403 :
@@ -164,7 +171,7 @@ def authenticate(self, retry=False):
164171
165172 # Get and check authentication status
166173 r = self .session .get (f'{ self .API_ENDPOINT } /authn/status' , headers = self .request_headers ,
167- proxies = self .proxies )
174+ proxies = self .proxies , timeout = self . timeout )
168175 if r .status_code == 200 :
169176 r_json = parse_json (r )
170177 if 'authenticated' in r_json and r_json ['authenticated' ] is True :
@@ -214,7 +221,7 @@ def api_get(self, url, params=None, data=None, headers=None):
214221 if headers is None :
215222 headers = self .request_headers
216223 r = self .session .get (url , params = params , data = data , headers = headers ,
217- proxies = self .proxies )
224+ proxies = self .proxies , timeout = self . timeout )
218225 self .update_token (r )
219226 return r
220227
@@ -230,7 +237,7 @@ def api_post(self, url, params, json, retry=False, timeout=None):
230237 """
231238 self ._last_err = None
232239 r = self .session .post (url , json = json , params = params , headers = self .request_headers ,
233- proxies = self .proxies , timeout = timeout )
240+ proxies = self .proxies , timeout = timeout if timeout is not None else self . timeout )
234241 self .update_token (r )
235242
236243 if r .status_code == 403 :
@@ -252,10 +259,10 @@ def api_post(self, url, params, json, retry=False, timeout=None):
252259 r_json = parse_json (r )
253260 if 'message' in (r_json or {}) and 'Authentication is required' in r_json ['message' ]:
254261 if retry :
255- logging .error (
262+ _logger .error (
256263 'API Post: Already retried... something must be wrong' )
257264 else :
258- logging .debug ("API Post: Retrying request with updated CSRF token" )
265+ _logger .debug ("API Post: Retrying request with updated CSRF token" )
259266 # try to authenticate
260267 self .authenticate ()
261268 # Try to authenticate and repeat the request 3 times -
@@ -275,7 +282,7 @@ def api_post_uri(self, url, params, uri_list, retry=False):
275282 """
276283 self ._last_err = None
277284 r = self .session .post (url , data = uri_list , params = params , headers = self .list_request_headers ,
278- proxies = self .proxies )
285+ proxies = self .proxies , timeout = self . timeout )
279286 self .update_token (r )
280287
281288 if r .status_code == 403 :
@@ -305,7 +312,7 @@ def api_put(self, url, params, json, retry=False):
305312 """
306313 self ._last_err = None
307314 r = self .session .put (url , params = params , json = json , headers = self .request_headers ,
308- proxies = self .proxies )
315+ proxies = self .proxies , timeout = self . timeout )
309316 self .update_token (r )
310317
311318 if r .status_code == 403 :
@@ -337,7 +344,7 @@ def api_put_uri(self, url, params, uri_list, retry=False):
337344 """
338345 self ._last_err = None
339346 r = self .session .put (url , params = params , data = uri_list , headers = self .list_request_headers ,
340- proxies = self .proxies )
347+ proxies = self .proxies , timeout = self . timeout )
341348 self .update_token (r )
342349
343350 if r .status_code == 403 :
@@ -368,7 +375,7 @@ def api_delete(self, url, params, retry=False):
368375 """
369376 self ._last_err = None
370377 r = self .session .delete (url , params = params , headers = self .request_headers ,
371- proxies = self .proxies )
378+ proxies = self .proxies , timeout = self . timeout )
372379 self .update_token (r )
373380
374381 if r .status_code == 403 :
@@ -401,15 +408,15 @@ def api_patch(self, url, operation, path, value, params=None, retry=False):
401408 """
402409 self ._last_err = None
403410 if url is None :
404- logging .error ('Missing required URL argument' )
411+ _logger .error ('Missing required URL argument' )
405412 return None
406413 if path is None :
407- logging .error ('Need valid path eg. /withdrawn or /metadata/dc.title/0/language' )
414+ _logger .error ('Need valid path eg. /withdrawn or /metadata/dc.title/0/language' )
408415 return None
409416 if (operation == self .PatchOperation .ADD or operation == self .PatchOperation .REPLACE
410417 or operation == self .PatchOperation .MOVE ) and value is None :
411418 # missing value required for add/replace/move operations
412- logging .error ('Missing required "value" argument for add/replace/move operations' )
419+ _logger .error ('Missing required "value" argument for add/replace/move operations' )
413420 return None
414421
415422 # compile patch data
@@ -426,7 +433,7 @@ def api_patch(self, url, operation, path, value, params=None, retry=False):
426433 # set headers
427434 # perform patch request
428435 r = self .session .patch (url , json = [data ], params = params , headers = self .request_headers ,
429- proxies = self .proxies )
436+ proxies = self .proxies , timeout = self . timeout )
430437 self .update_token (r )
431438
432439 if r .status_code == 403 :
@@ -635,7 +642,7 @@ def update_dso(self, dso, params=None):
635642 return None
636643 dso_type = type (dso )
637644 if not isinstance (dso , SimpleDSpaceObject ):
638- logging .error ('Only SimpleDSpaceObject types (eg Item, Collection, Community) '
645+ _logger .error ('Only SimpleDSpaceObject types (eg Item, Collection, Community) '
639646 'are supported by generic update_dso PUT.' )
640647 return dso
641648 try :
@@ -682,11 +689,11 @@ def delete_dso(self, dso=None, url=None, params=None):
682689 """
683690 if dso is None :
684691 if url is None :
685- logging .error ('Need a DSO or a URL to delete' )
692+ _logger .error ('Need a DSO or a URL to delete' )
686693 return None
687694 else :
688695 if not isinstance (dso , SimpleDSpaceObject ):
689- logging .error ('Only SimpleDSpaceObject types (eg Item, Collection, Community, EPerson) '
696+ _logger .error ('Only SimpleDSpaceObject types (eg Item, Collection, Community, EPerson) '
690697 'are supported by generic update_dso PUT.' )
691698 return dso
692699 # Get self URI from HAL links
@@ -844,15 +851,17 @@ def create_bitstream(self, bundle=None, name=None, path=None, mime=None, metadat
844851 if metadata is None :
845852 metadata = {}
846853 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 )
854+ # open the file in a context manager so the handle is always closed,
855+ # even if prepare/send raises (it was previously leaked to the GC).
856+ with open (path , 'rb' ) as fh :
857+ files = {'file' : (name , fh , mime )}
858+ properties = {'name' : name , 'metadata' : metadata , 'bundleName' : bundle .name }
859+ payload = {'properties' : json .dumps (properties ) + ';application/json' }
860+ h = self .session .headers
861+ h .update ({'Content-Encoding' : 'gzip' , 'User-Agent' : self .USER_AGENT })
862+ req = Request ('POST' , url , data = payload , headers = h , files = files )
863+ prepared_req = self .session .prepare_request (req )
864+ r = self .session .send (prepared_req , proxies = self .proxies , timeout = self .timeout )
856865 if 'DSPACE-XSRF-TOKEN' in r .headers :
857866 t = r .headers ['DSPACE-XSRF-TOKEN' ]
858867 _logger .debug ('Updating token to ' + t )
@@ -1200,7 +1209,7 @@ def create_user(self, user, token=None):
12001209
12011210 def delete_user (self , user ):
12021211 if not isinstance (user , User ):
1203- logging .error ('Must be a valid user' )
1212+ _logger .error ('Must be a valid user' )
12041213 return None
12051214 return self .delete_dso (user )
12061215
@@ -1430,16 +1439,21 @@ def get_clarinlruallowances_by_bitstream_and_user(self, bitstream_uuid, user_uui
14301439 return None
14311440
14321441
1433- def create_clarinlruallowances (self , bitstream_uuid ):
1442+ def create_clarinlruallowances (self , bitstream_uuid , metadata_payload = None ):
14341443 """
1435- Create clarinlruallowances for a bitstream for logged user
1436- by managing user metadata of bitstream.
1444+ Create clarinlruallowances for a bitstream for the logged-in user by
1445+ managing the bitstream's user metadata.
1446+ @param bitstream_uuid: target bitstream UUID
1447+ @param metadata_payload: list of {"metadataKey", "metadataValue"} dicts.
1448+ Required - there is no meaningful default (the
1449+ previous hardcoded "Test" value was leftover
1450+ debug data, not usable for real callers).
14371451 """
1452+ if not metadata_payload :
1453+ _logger .error ('create_clarinlruallowances requires a metadata_payload' )
1454+ return False
14381455 url = f'{ self .API_ENDPOINT } /core/clarinusermetadata/manage'
14391456 params = {'bitstreamUUID' : bitstream_uuid }
1440- metadata_payload = [
1441- {"metadataKey" : "NAME" , "metadataValue" : "Test" }
1442- ]
14431457 try :
14441458 response = self .api_post (url , json = metadata_payload , params = params )
14451459 if response .status_code == 200 :
0 commit comments