Skip to content

Commit feeb399

Browse files
jmclaude
andcommitted
fix(client): correctness sweep - logging, timeouts, file handle, None guards
Python-hygiene fixes (no behaviour change on the happy path): - Remove logging.basicConfig() at import - a library must not configure the root logger. Attach a NullHandler and route everything through the module _logger (dropped the stray root logging.* calls). - Add a configurable per-request timeout (DEFAULT_TIMEOUT=60, `timeout=` constructor arg) on every session call, so a stalled server cannot hang the client forever. - create_bitstream: open the upload file in a `with` block so the handle is always closed instead of leaked to the GC. - Bitstream(None) no longer raises TypeError on the __init__ membership checks. - create_clarinlruallowances: parameterise metadata_payload; drop the leftover hardcoded {"metadataValue":"Test"} debug data (refuses with no payload). - models: modernise super(Cls, self) -> super() throughout. Mutable class-attribute defaults were intentionally left untouched. 68 tests (5 new), no network. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dbabf5d commit feeb399

5 files changed

Lines changed: 120 additions & 59 deletions

File tree

dspace_rest_client/client.py

Lines changed: 49 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@
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

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

Comments
 (0)