dspace-api-toolkit 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dspace_api/__init__.py +75 -0
- dspace_api/_base.py +407 -0
- dspace_api/_containers.py +217 -0
- dspace_api/_version.py +4 -0
- dspace_api/auth.py +467 -0
- dspace_api/authorizations.py +486 -0
- dspace_api/bundles.py +1193 -0
- dspace_api/collections.py +1069 -0
- dspace_api/communities.py +363 -0
- dspace_api/config.py +254 -0
- dspace_api/discovery.py +920 -0
- dspace_api/entity_types.py +127 -0
- dspace_api/epersons.py +647 -0
- dspace_api/exceptions.py +110 -0
- dspace_api/groups.py +599 -0
- dspace_api/items.py +549 -0
- dspace_api/metadata.py +742 -0
- dspace_api/permissions.py +1052 -0
- dspace_api/projection.py +292 -0
- dspace_api/relationships.py +694 -0
- dspace_api/session.py +538 -0
- dspace_api/site.py +98 -0
- dspace_api/utils.py +519 -0
- dspace_api/versions.py +292 -0
- dspace_api/vocabulary.py +365 -0
- dspace_api/workflow.py +893 -0
- dspace_api/workspaces.py +1404 -0
- dspace_api_toolkit-0.1.1.dist-info/METADATA +211 -0
- dspace_api_toolkit-0.1.1.dist-info/RECORD +32 -0
- dspace_api_toolkit-0.1.1.dist-info/WHEEL +5 -0
- dspace_api_toolkit-0.1.1.dist-info/licenses/LICENSE +28 -0
- dspace_api_toolkit-0.1.1.dist-info/top_level.txt +1 -0
dspace_api/session.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Resilient HTTP session for the DSpace API toolkit.
|
|
4
|
+
|
|
5
|
+
DSpaceAuth creates a DSpaceSession; every manager uses it through
|
|
6
|
+
``auth.session`` - including the older managers that call ``session.get()``
|
|
7
|
+
directly - so all requests get the same behaviour:
|
|
8
|
+
|
|
9
|
+
- Transient failures are retried with exponential backoff:
|
|
10
|
+
- HTTP 429 (any method - the server did not process the request),
|
|
11
|
+
honouring Retry-After
|
|
12
|
+
- HTTP 502/503/504 (configurable) for idempotent methods only
|
|
13
|
+
- connection errors and read timeouts for idempotent methods only
|
|
14
|
+
- connect timeouts for any method (the request never reached the server)
|
|
15
|
+
POST and PATCH are NOT retried after a 5xx or a broken connection,
|
|
16
|
+
because DSpace may already have created the bitstream / applied the patch.
|
|
17
|
+
|
|
18
|
+
- The login token (JWT) is refreshed shortly before it expires, and a 401
|
|
19
|
+
triggers one re-login with the stored credentials and one retry.
|
|
20
|
+
|
|
21
|
+
- A 403 that carries a fresh CSRF token is retried once (non-GET only).
|
|
22
|
+
|
|
23
|
+
- Optional projections (allLanguages, preventMetadataSecurity,
|
|
24
|
+
preventPointsCalculation) exist only on some servers (DSpace-CRIS,
|
|
25
|
+
DSpace 10). Plain DSpace 8/9 answer an unknown projection with 400/404.
|
|
26
|
+
On such an answer the session checks once per projection whether the
|
|
27
|
+
server knows it (GET /core/sites?projection=X), remembers the result,
|
|
28
|
+
drops unsupported ones from this and every later request and resends.
|
|
29
|
+
|
|
30
|
+
- Metadata PATCH on archived objects: DSpace uses only the FIRST value of
|
|
31
|
+
an array in "add /metadata/<field>" and "replace /metadata/<field>". An
|
|
32
|
+
operation with several values is sent as "remove" (replace only) plus
|
|
33
|
+
one "add /metadata/<field>/-" per value, so no value is lost silently.
|
|
34
|
+
|
|
35
|
+
- DSpace-CRIS answers every request of a user who has not accepted the
|
|
36
|
+
end-user agreement with 403 "No user agreement accepted". With
|
|
37
|
+
config.accept_user_agreement the agreement is accepted for the logged-in
|
|
38
|
+
user, the token is refreshed (the flag is a token claim) and the request
|
|
39
|
+
is sent again; otherwise the 403 surfaces as UserAgreementRequired.
|
|
40
|
+
|
|
41
|
+
Requests made by DSpaceAuth itself (login, status, CSRF, logout) run in an
|
|
42
|
+
"internal" scope: they are retried on transient errors but never trigger a
|
|
43
|
+
token refresh or re-login, which would recurse.
|
|
44
|
+
|
|
45
|
+
Bodies are re-sent on retry: pass bytes / strings / dicts (as the toolkit
|
|
46
|
+
does), not one-shot generators or open file objects.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
import email.utils
|
|
50
|
+
import json
|
|
51
|
+
import logging
|
|
52
|
+
import random
|
|
53
|
+
import threading
|
|
54
|
+
import time
|
|
55
|
+
import weakref
|
|
56
|
+
from contextlib import contextmanager
|
|
57
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
58
|
+
|
|
59
|
+
import requests
|
|
60
|
+
|
|
61
|
+
from .config import DEFAULT_RETRY_STATUSES
|
|
62
|
+
|
|
63
|
+
# Logger for the module
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
# Constants for session management and DSpace API behavior
|
|
67
|
+
|
|
68
|
+
# XSRF token header used by DSpace
|
|
69
|
+
XSRF_RESPONSE_HEADER = "DSPACE-XSRF-TOKEN"
|
|
70
|
+
# HTTP methods considered idempotent by the session manager
|
|
71
|
+
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
|
|
72
|
+
# The server did not process the request -> safe to retry any method
|
|
73
|
+
ALWAYS_RETRYABLE_STATUSES = frozenset({429})
|
|
74
|
+
|
|
75
|
+
# Projections not every server has (see module docstring)
|
|
76
|
+
OPTIONAL_PROJECTIONS = frozenset({"allLanguages", "preventMetadataSecurity", "preventPointsCalculation"})
|
|
77
|
+
UNKNOWN_PROJECTION_STATUSES = frozenset({400, 404})
|
|
78
|
+
# Message indicating that the user has not accepted the end-user agreement
|
|
79
|
+
USER_AGREEMENT_MESSAGE = "No user agreement accepted"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _projection_values(value):
|
|
83
|
+
"""Normalize projection values into a list of names."""
|
|
84
|
+
values = value if isinstance(value, (list, tuple)) else [value]
|
|
85
|
+
names = []
|
|
86
|
+
for v in values:
|
|
87
|
+
names.extend(n.strip() for n in str(v).split(",") if n.strip())
|
|
88
|
+
return names
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def requested_projections(url, params):
|
|
92
|
+
"""Names of the projections in the URL query string and in `params`."""
|
|
93
|
+
names = [n for key, value in parse_qsl(urlsplit(url).query) if key == "projection"
|
|
94
|
+
for n in _projection_values(value)]
|
|
95
|
+
if isinstance(params, dict):
|
|
96
|
+
names.extend(_projection_values(params["projection"]) if params.get("projection") is not None else [])
|
|
97
|
+
elif isinstance(params, (list, tuple)):
|
|
98
|
+
for pair in params:
|
|
99
|
+
if isinstance(pair, (list, tuple)) and len(pair) == 2 and pair[0] == "projection":
|
|
100
|
+
names.extend(_projection_values(pair[1]))
|
|
101
|
+
return names
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _keep(value, drop):
|
|
105
|
+
"""Keep only the projection values not in `drop`."""
|
|
106
|
+
kept = [n for n in _projection_values(value) if n not in drop]
|
|
107
|
+
return ",".join(kept) if kept else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def strip_projections(url, params, drop):
|
|
111
|
+
"""(url, params) without the projections in `drop` (same params shape)."""
|
|
112
|
+
parts = urlsplit(url)
|
|
113
|
+
if parts.query:
|
|
114
|
+
query = []
|
|
115
|
+
for key, value in parse_qsl(parts.query, keep_blank_values=True):
|
|
116
|
+
if key == "projection":
|
|
117
|
+
value = _keep(value, drop)
|
|
118
|
+
if value is None:
|
|
119
|
+
continue
|
|
120
|
+
query.append((key, value))
|
|
121
|
+
url = urlunsplit(parts._replace(query=urlencode(query)))
|
|
122
|
+
if isinstance(params, dict):
|
|
123
|
+
params = dict(params)
|
|
124
|
+
if params.get("projection") is not None:
|
|
125
|
+
values = params["projection"] if isinstance(params["projection"], (list, tuple)) else [params["projection"]]
|
|
126
|
+
kept = [k for k in (_keep(v, drop) for v in values) if k is not None]
|
|
127
|
+
if kept:
|
|
128
|
+
params["projection"] = kept if len(kept) > 1 else kept[0]
|
|
129
|
+
else:
|
|
130
|
+
del params["projection"]
|
|
131
|
+
elif isinstance(params, (list, tuple)):
|
|
132
|
+
cleaned = []
|
|
133
|
+
for pair in params:
|
|
134
|
+
if isinstance(pair, (list, tuple)) and len(pair) == 2 and pair[0] == "projection":
|
|
135
|
+
value = _keep(pair[1], drop)
|
|
136
|
+
if value is None:
|
|
137
|
+
continue
|
|
138
|
+
pair = (pair[0], value)
|
|
139
|
+
cleaned.append(pair)
|
|
140
|
+
params = cleaned
|
|
141
|
+
return url, params
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_value_list(value):
|
|
145
|
+
"""Check if the value is a list with more than one element."""
|
|
146
|
+
return isinstance(value, list) and len(value) > 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def expand_metadata_operations(operations):
|
|
150
|
+
"""
|
|
151
|
+
Rewrite multi-value 'add'/'replace' on /metadata/<field> into operations
|
|
152
|
+
DSpace applies completely (see module docstring). Other operations, and
|
|
153
|
+
single values, are returned unchanged. Returns None if nothing changed.
|
|
154
|
+
|
|
155
|
+
Parameters:
|
|
156
|
+
operations: List of metadata operations to expand
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
list: Expanded list of operations, or None if no changes were made
|
|
160
|
+
"""
|
|
161
|
+
if not isinstance(operations, list):
|
|
162
|
+
return None
|
|
163
|
+
result, changed = [], False
|
|
164
|
+
for op in operations:
|
|
165
|
+
path = op.get("path") if isinstance(op, dict) else None
|
|
166
|
+
if (isinstance(path, str) and path.startswith("/metadata/") and path.count("/") == 2
|
|
167
|
+
and op.get("op") in ("add", "replace") and _is_value_list(op.get("value"))):
|
|
168
|
+
if op["op"] == "replace":
|
|
169
|
+
result.append({"op": "remove", "path": path})
|
|
170
|
+
result.extend({"op": "add", "path": f"{path}/-", "value": value} for value in op["value"])
|
|
171
|
+
changed = True
|
|
172
|
+
else:
|
|
173
|
+
result.append(op)
|
|
174
|
+
return result if changed else None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def is_user_agreement_response(response):
|
|
178
|
+
"""
|
|
179
|
+
Check if the response indicates that the user has not accepted the end-user agreement.
|
|
180
|
+
|
|
181
|
+
Parameters:
|
|
182
|
+
response: The HTTP response object to check
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
bool: True if the response indicates a missing user agreement, False otherwise
|
|
186
|
+
"""
|
|
187
|
+
if response.status_code != 403:
|
|
188
|
+
return False
|
|
189
|
+
try:
|
|
190
|
+
return USER_AGREEMENT_MESSAGE in (response.text or "")
|
|
191
|
+
except (AttributeError, TypeError, ValueError): # pragma: no cover - defensive
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def parse_retry_after(value, now=None):
|
|
196
|
+
"""Seconds to wait from a Retry-After header (delta-seconds or HTTP date), or None.
|
|
197
|
+
|
|
198
|
+
Parameters:
|
|
199
|
+
value: The value of the Retry-After header
|
|
200
|
+
now: Current time as a timestamp, used for calculating the wait time
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
float: Number of seconds to wait, or None if the header is invalid
|
|
204
|
+
"""
|
|
205
|
+
if not value:
|
|
206
|
+
return None
|
|
207
|
+
value = value.strip()
|
|
208
|
+
if value.isdigit():
|
|
209
|
+
return float(value)
|
|
210
|
+
try:
|
|
211
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
212
|
+
except (TypeError, ValueError, IndexError):
|
|
213
|
+
return None
|
|
214
|
+
if when is None: # pragma: no cover - older Pythons return None instead of raising
|
|
215
|
+
return None
|
|
216
|
+
now = time.time() if now is None else now
|
|
217
|
+
return max(0.0, when.timestamp() - now)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class _Settings:
|
|
221
|
+
"""Retry settings used when the session has no auth/config attached."""
|
|
222
|
+
max_retries = 3 # Maximum number of retry attempts
|
|
223
|
+
retry_backoff = 1.0 # Backoff factor for retry delays
|
|
224
|
+
retry_max_wait = 30.0 # Maximum wait time between retries in seconds
|
|
225
|
+
retry_statuses = frozenset(DEFAULT_RETRY_STATUSES)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class DSpaceSession(requests.Session):
|
|
229
|
+
"""requests.Session with retries, token refresh, re-login and CSRF retry"""
|
|
230
|
+
|
|
231
|
+
def __init__(self, auth=None):
|
|
232
|
+
"""Initialize the DSpaceSession.
|
|
233
|
+
|
|
234
|
+
Parameters:
|
|
235
|
+
auth: Optional authentication object to attach to the session
|
|
236
|
+
"""
|
|
237
|
+
super().__init__()
|
|
238
|
+
# weakref: the auth object owns the session, avoid a reference cycle
|
|
239
|
+
self._auth_ref = weakref.ref(auth) if auth is not None else None
|
|
240
|
+
self._local = threading.local()
|
|
241
|
+
self.sleep = time.sleep # replaceable in tests
|
|
242
|
+
# projection name -> True (server knows it) / False (unknown, dropped)
|
|
243
|
+
self.projection_support = {}
|
|
244
|
+
self._projection_lock = threading.Lock()
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# Scope for DSpaceAuth's own requests
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def dspace_auth(self):
|
|
252
|
+
"""Get the current DSpaceAuth instance attached to the session.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
DSpaceAuth or None: The authentication object if attached, None otherwise
|
|
256
|
+
"""
|
|
257
|
+
return self._auth_ref() if self._auth_ref is not None else None
|
|
258
|
+
|
|
259
|
+
@contextmanager
|
|
260
|
+
def internal(self):
|
|
261
|
+
"""Context manager for marking requests as internal to the DSpaceAuth session.
|
|
262
|
+
|
|
263
|
+
Yields:
|
|
264
|
+
DSpaceSession: The current session instance
|
|
265
|
+
"""
|
|
266
|
+
self._local.depth = getattr(self._local, "depth", 0) + 1
|
|
267
|
+
try:
|
|
268
|
+
yield self
|
|
269
|
+
finally:
|
|
270
|
+
self._local.depth -= 1
|
|
271
|
+
|
|
272
|
+
def _is_internal(self):
|
|
273
|
+
"""Check if the current request is internal to the DSpaceAuth session.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
bool: True if the request is internal, False otherwise
|
|
277
|
+
"""
|
|
278
|
+
return getattr(self._local, "depth", 0) > 0
|
|
279
|
+
|
|
280
|
+
def _settings(self):
|
|
281
|
+
"""Get the settings for the current session, falling back to default settings if necessary.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
_Settings: The settings object to use for the session
|
|
285
|
+
"""
|
|
286
|
+
auth = self.dspace_auth
|
|
287
|
+
config = getattr(auth, "config", None)
|
|
288
|
+
return config if config is not None and hasattr(config, "max_retries") else _Settings
|
|
289
|
+
|
|
290
|
+
# ------------------------------------------------------------------
|
|
291
|
+
# Request pipeline
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def request(self, method, url, *args, **kwargs):
|
|
295
|
+
"""Send an HTTP request through the DSpace session, handling authentication, retries, and projections.
|
|
296
|
+
|
|
297
|
+
Parameters:
|
|
298
|
+
method (str): The HTTP method (e.g., "GET", "POST", "PATCH")
|
|
299
|
+
url (str): The URL to send the request to
|
|
300
|
+
*args: Additional positional arguments to pass to the request
|
|
301
|
+
**kwargs: Additional keyword arguments to pass to the request
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
Response: The HTTP response object
|
|
305
|
+
"""
|
|
306
|
+
auth = self.dspace_auth
|
|
307
|
+
if auth is None or self._is_internal():
|
|
308
|
+
return self._request_with_retries(method, url, args, kwargs)
|
|
309
|
+
|
|
310
|
+
auth.ensure_fresh_token()
|
|
311
|
+
is_get = method.upper() == "GET"
|
|
312
|
+
if is_get:
|
|
313
|
+
url, kwargs = self._without_unsupported_projections(url, kwargs)
|
|
314
|
+
elif method.upper() == "PATCH":
|
|
315
|
+
kwargs = self._expand_metadata_patch(kwargs)
|
|
316
|
+
response = self._send(auth, method, url, args, kwargs)
|
|
317
|
+
|
|
318
|
+
if is_get and response.status_code in UNKNOWN_PROJECTION_STATUSES:
|
|
319
|
+
retry = self._drop_unknown_projections(auth, url, kwargs)
|
|
320
|
+
if retry is not None:
|
|
321
|
+
response.close()
|
|
322
|
+
url, kwargs = retry
|
|
323
|
+
response = self._send(auth, method, url, args, kwargs)
|
|
324
|
+
|
|
325
|
+
# DSpace answers a stale CSRF token with 403 + a fresh token
|
|
326
|
+
if (response.status_code == 403 and method.upper() != "GET"
|
|
327
|
+
and XSRF_RESPONSE_HEADER in response.headers):
|
|
328
|
+
logger.debug("Retrying %s %s with refreshed CSRF token", method, url)
|
|
329
|
+
response = self._send(auth, method, url, args, kwargs)
|
|
330
|
+
|
|
331
|
+
# Expired / revoked login: log in again once and repeat the request
|
|
332
|
+
if response.status_code == 401 and auth.can_reauthenticate():
|
|
333
|
+
logger.warning("%s %s returned 401 - logging in again and retrying once", method, url)
|
|
334
|
+
auth.reauthenticate()
|
|
335
|
+
response = self._send(auth, method, url, args, kwargs)
|
|
336
|
+
|
|
337
|
+
# DSpace-CRIS: the logged-in user has not accepted the end-user agreement
|
|
338
|
+
if (is_user_agreement_response(response) and getattr(auth.config, "accept_user_agreement", False)
|
|
339
|
+
and getattr(auth, "is_authenticated", False)):
|
|
340
|
+
logger.warning("%s %s: no user agreement accepted - accepting it for the logged-in user "
|
|
341
|
+
"(accept_user_agreement is on) and retrying once", method, url)
|
|
342
|
+
if auth.accept_user_agreement():
|
|
343
|
+
response.close()
|
|
344
|
+
response = self._send(auth, method, url, args, kwargs)
|
|
345
|
+
|
|
346
|
+
return response
|
|
347
|
+
|
|
348
|
+
# ------------------------------------------------------------------
|
|
349
|
+
# Optional projections and metadata PATCH
|
|
350
|
+
# ------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
def _without_unsupported_projections(self, url, kwargs):
|
|
353
|
+
"""Remove projections from the request that are known to be unsupported by the server.
|
|
354
|
+
|
|
355
|
+
Parameters:
|
|
356
|
+
url (str): The request URL
|
|
357
|
+
kwargs (dict): The request keyword arguments, including "params"
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
tuple: A tuple of (url, kwargs) with unsupported projections removed
|
|
361
|
+
"""
|
|
362
|
+
unsupported = {name for name, ok in self.projection_support.items() if ok is False}
|
|
363
|
+
if not unsupported:
|
|
364
|
+
return url, kwargs
|
|
365
|
+
params = kwargs.get("params")
|
|
366
|
+
if not unsupported.intersection(requested_projections(url, params)):
|
|
367
|
+
return url, kwargs
|
|
368
|
+
url, params = strip_projections(url, params, unsupported)
|
|
369
|
+
return url, {**kwargs, "params": params}
|
|
370
|
+
|
|
371
|
+
def _drop_unknown_projections(self, auth, url, kwargs):
|
|
372
|
+
"""Return (url, kwargs) without projections the server turned out not to know, or None if no unknown projections were found.
|
|
373
|
+
|
|
374
|
+
Parameters:
|
|
375
|
+
auth: The authentication object to use for checking projection support
|
|
376
|
+
url (str): The request URL
|
|
377
|
+
kwargs (dict): The request keyword arguments, including "params"
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
tuple or None: A tuple of (url, kwargs) with unknown projections removed, or None if no unknown projections were found
|
|
381
|
+
"""
|
|
382
|
+
candidates = [name for name in dict.fromkeys(requested_projections(url, kwargs.get("params")))
|
|
383
|
+
if name in OPTIONAL_PROJECTIONS and self.projection_support.get(name) is None]
|
|
384
|
+
unknown = {name for name in candidates if not self.supports_projection(name, auth)}
|
|
385
|
+
if not unknown:
|
|
386
|
+
return None
|
|
387
|
+
url, params = strip_projections(url, kwargs.get("params"), unknown)
|
|
388
|
+
return url, {**kwargs, "params": params}
|
|
389
|
+
|
|
390
|
+
def supports_projection(self, name, auth=None):
|
|
391
|
+
"""
|
|
392
|
+
Check if the server supports the given projection.
|
|
393
|
+
|
|
394
|
+
Whether the server knows projection `name` (checked once with
|
|
395
|
+
GET /core/sites?projection=name, then remembered). Unclear answers
|
|
396
|
+
(e.g. 5xx, network errors) count as supported and are checked again later.
|
|
397
|
+
|
|
398
|
+
Parameters:
|
|
399
|
+
name (str): The name of the projection to check
|
|
400
|
+
auth: Optional authentication object to use for the check
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
bool: True if the server supports the projection, False otherwise
|
|
404
|
+
"""
|
|
405
|
+
known = self.projection_support.get(name)
|
|
406
|
+
if known is not None:
|
|
407
|
+
return known
|
|
408
|
+
auth = auth or self.dspace_auth
|
|
409
|
+
base = getattr(getattr(auth, "config", None), "dspace_url", None)
|
|
410
|
+
if not base:
|
|
411
|
+
return True
|
|
412
|
+
with self._projection_lock:
|
|
413
|
+
if name in self.projection_support:
|
|
414
|
+
return self.projection_support[name]
|
|
415
|
+
try:
|
|
416
|
+
with self.internal():
|
|
417
|
+
probe = self._request_with_retries("GET", f"{base}/core/sites", (),
|
|
418
|
+
{"params": {"projection": name, "size": 1},
|
|
419
|
+
"timeout": getattr(auth.config, "timeout", 30)})
|
|
420
|
+
except requests.exceptions.RequestException as exc:
|
|
421
|
+
logger.debug("Projection check for %s failed: %s", name, exc)
|
|
422
|
+
return True
|
|
423
|
+
status = probe.status_code
|
|
424
|
+
probe.close()
|
|
425
|
+
if status == 200:
|
|
426
|
+
self.projection_support[name] = True
|
|
427
|
+
elif status in UNKNOWN_PROJECTION_STATUSES:
|
|
428
|
+
self.projection_support[name] = False
|
|
429
|
+
logger.warning("This server does not know projection=%s (DSpace-CRIS / DSpace 10 only) - "
|
|
430
|
+
"requests are sent without it from now on", name)
|
|
431
|
+
else:
|
|
432
|
+
return True
|
|
433
|
+
return self.projection_support[name]
|
|
434
|
+
|
|
435
|
+
@staticmethod
|
|
436
|
+
def _expand_metadata_patch(kwargs):
|
|
437
|
+
"""Expand metadata operations in the request payload.
|
|
438
|
+
|
|
439
|
+
Parameters:
|
|
440
|
+
kwargs (dict): The request keyword arguments, potentially containing "json" or "data"
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
dict: The modified keyword arguments with expanded metadata operations
|
|
444
|
+
"""
|
|
445
|
+
if "json" in kwargs:
|
|
446
|
+
expanded = expand_metadata_operations(kwargs["json"])
|
|
447
|
+
return {**kwargs, "json": expanded} if expanded is not None else kwargs
|
|
448
|
+
data = kwargs.get("data")
|
|
449
|
+
if isinstance(data, (str, bytes)) and data[:1] in ("[", b"["):
|
|
450
|
+
try:
|
|
451
|
+
operations = json.loads(data)
|
|
452
|
+
except ValueError:
|
|
453
|
+
return kwargs
|
|
454
|
+
expanded = expand_metadata_operations(operations)
|
|
455
|
+
if expanded is not None:
|
|
456
|
+
return {**kwargs, "data": json.dumps(expanded)}
|
|
457
|
+
return kwargs
|
|
458
|
+
|
|
459
|
+
def _send(self, auth, method, url, args, kwargs):
|
|
460
|
+
"""Send an HTTP request with authentication and handle token updates.
|
|
461
|
+
|
|
462
|
+
Parameters:
|
|
463
|
+
auth: The authentication object to use for the request
|
|
464
|
+
method (str): The HTTP method (e.g., "GET", "POST", "PATCH")
|
|
465
|
+
url (str): The URL to send the request to
|
|
466
|
+
args: Additional positional arguments to pass to the request
|
|
467
|
+
kwargs: Additional keyword arguments to pass to the request
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
Response: The HTTP response object
|
|
471
|
+
"""
|
|
472
|
+
response = self._request_with_retries(method, url, args, kwargs)
|
|
473
|
+
auth._update_token(response)
|
|
474
|
+
return response
|
|
475
|
+
|
|
476
|
+
def _backoff(self, settings, attempt):
|
|
477
|
+
"""Calculate the backoff time for a retry attempt.
|
|
478
|
+
|
|
479
|
+
Parameters:
|
|
480
|
+
settings (_Settings): The session settings containing retry configuration
|
|
481
|
+
attempt (int): The current retry attempt number
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
float: The time to wait before the next retry, in seconds
|
|
485
|
+
"""
|
|
486
|
+
base = settings.retry_backoff * (2 ** attempt)
|
|
487
|
+
# up to 10% jitter so parallel workers don't retry in lockstep
|
|
488
|
+
return min(settings.retry_max_wait, base + random.uniform(0, base * 0.1))
|
|
489
|
+
|
|
490
|
+
def _request_with_retries(self, method, url, args, kwargs):
|
|
491
|
+
"""Send an HTTP request with retries for transient errors.
|
|
492
|
+
|
|
493
|
+
Parameters:
|
|
494
|
+
method (str): The HTTP method (e.g., "GET", "POST", "PATCH")
|
|
495
|
+
url (str): The URL to send the request to
|
|
496
|
+
args: Additional positional arguments to pass to the request
|
|
497
|
+
kwargs: Additional keyword arguments to pass to the request
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
Response: The HTTP response object
|
|
501
|
+
|
|
502
|
+
Raises:
|
|
503
|
+
requests.exceptions.RequestException: If the request fails and is not retryable
|
|
504
|
+
"""
|
|
505
|
+
settings = self._settings()
|
|
506
|
+
idempotent = method.upper() in IDEMPOTENT_METHODS
|
|
507
|
+
attempt = 0
|
|
508
|
+
while True:
|
|
509
|
+
try:
|
|
510
|
+
response = super().request(method, url, *args, **kwargs)
|
|
511
|
+
except requests.exceptions.ConnectTimeout as exc:
|
|
512
|
+
error, retryable = exc, True # never reached the server
|
|
513
|
+
except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout) as exc:
|
|
514
|
+
error, retryable = exc, idempotent # may have been processed
|
|
515
|
+
else:
|
|
516
|
+
status = response.status_code
|
|
517
|
+
retryable = attempt < settings.max_retries and (
|
|
518
|
+
status in ALWAYS_RETRYABLE_STATUSES
|
|
519
|
+
or (idempotent and status in settings.retry_statuses))
|
|
520
|
+
if not retryable:
|
|
521
|
+
return response
|
|
522
|
+
retry_after = parse_retry_after(response.headers.get("Retry-After"))
|
|
523
|
+
wait = (min(settings.retry_max_wait, retry_after) if retry_after is not None
|
|
524
|
+
else self._backoff(settings, attempt))
|
|
525
|
+
logger.warning("%s %s returned %s - retry %d/%d in %.1fs",
|
|
526
|
+
method, url, status, attempt + 1, settings.max_retries, wait)
|
|
527
|
+
response.close()
|
|
528
|
+
self.sleep(wait)
|
|
529
|
+
attempt += 1
|
|
530
|
+
continue
|
|
531
|
+
|
|
532
|
+
if not retryable or attempt >= settings.max_retries:
|
|
533
|
+
raise error
|
|
534
|
+
wait = self._backoff(settings, attempt)
|
|
535
|
+
logger.warning("%s %s failed (%s) - retry %d/%d in %.1fs",
|
|
536
|
+
method, url, type(error).__name__, attempt + 1, settings.max_retries, wait)
|
|
537
|
+
self.sleep(wait)
|
|
538
|
+
attempt += 1
|
dspace_api/site.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Site object and Terms of Use for DSpace API
|
|
4
|
+
|
|
5
|
+
The repository-wide Terms of Use text is the Site object's dc.rights
|
|
6
|
+
metadata (one value per language, may contain Markdown). Whether a user
|
|
7
|
+
accepted it is stored on the eperson - see EPersonManager.*_end_user_agreement.
|
|
8
|
+
|
|
9
|
+
GET /core/sites?projection=allLanguages
|
|
10
|
+
PATCH /core/sites/{uuid} metadata (admin)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from ._base import BaseManager
|
|
17
|
+
from .config import Endpoints
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
SITES = getattr(Endpoints, "SITES", "/core/sites")
|
|
22
|
+
TERMS_FIELD = "dc.rights"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SiteManager(BaseManager):
|
|
26
|
+
"""Reads and updates the Site object, in particular the Terms of Use"""
|
|
27
|
+
|
|
28
|
+
def get_site(self, all_languages: Optional[bool] = True) -> Optional[Dict[str, Any]]:
|
|
29
|
+
"""
|
|
30
|
+
The single Site object. Defaults to all_languages=True (unlike the
|
|
31
|
+
other managers), because the Terms of Use are stored per language;
|
|
32
|
+
pass None to follow the client setting, False to switch it off.
|
|
33
|
+
|
|
34
|
+
Parameters:
|
|
35
|
+
all_languages: Whether to retrieve all language versions of the site metadata
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
dict: The site object, or None if not found
|
|
39
|
+
"""
|
|
40
|
+
data = self._get_json(self._url(SITES), "Get site", all_languages=all_languages)
|
|
41
|
+
sites = data.get("_embedded", {}).get("sites", [])
|
|
42
|
+
return sites[0] if sites else None
|
|
43
|
+
|
|
44
|
+
def get_terms_of_use_values(self) -> List[Dict[str, Any]]:
|
|
45
|
+
"""
|
|
46
|
+
Get the raw Terms of Use metadata values.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
list: List of metadata values for the Terms of Use
|
|
50
|
+
"""
|
|
51
|
+
site = self.get_site() or {}
|
|
52
|
+
return (site.get("metadata") or {}).get(TERMS_FIELD) or []
|
|
53
|
+
|
|
54
|
+
def get_terms_of_use(self, language: str = "en", fallback_language: Optional[str] = "en") -> Optional[str]:
|
|
55
|
+
"""
|
|
56
|
+
Terms text in `language`, else `fallback_language`, else the first
|
|
57
|
+
non-empty value. None if no terms are configured.
|
|
58
|
+
|
|
59
|
+
Parameters:
|
|
60
|
+
language: Preferred language for the terms
|
|
61
|
+
fallback_language: Language to fall back to if the preferred language is not available
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
str: Terms of Use text in the requested language, or None if not available
|
|
65
|
+
"""
|
|
66
|
+
values = [v for v in self.get_terms_of_use_values() if (v.get("value") or "").strip()]
|
|
67
|
+
for lang in (language, fallback_language):
|
|
68
|
+
match = next((v for v in values if v.get("language") == lang), None) if lang else None
|
|
69
|
+
if match:
|
|
70
|
+
return match["value"].strip()
|
|
71
|
+
return values[0]["value"].strip() if values else None
|
|
72
|
+
|
|
73
|
+
def set_terms_of_use(self, text: str, language: Optional[str] = "en") -> Dict[str, Any]:
|
|
74
|
+
"""
|
|
75
|
+
Create or replace the terms for one language, leaving other languages alone (admin).
|
|
76
|
+
|
|
77
|
+
Parameters:
|
|
78
|
+
text: The terms text to set
|
|
79
|
+
language: Language for the terms
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
dict: The updated site object
|
|
83
|
+
"""
|
|
84
|
+
site = self.get_site()
|
|
85
|
+
if not site:
|
|
86
|
+
raise ValueError("No Site object found")
|
|
87
|
+
values = (site.get("metadata") or {}).get(TERMS_FIELD) or []
|
|
88
|
+
new_value = {"value": text, "language": language}
|
|
89
|
+
index = next((i for i, v in enumerate(values) if v.get("language") == language), None)
|
|
90
|
+
if index is not None:
|
|
91
|
+
op = {"op": "replace", "path": f"/metadata/{TERMS_FIELD}/{index}", "value": new_value}
|
|
92
|
+
elif values:
|
|
93
|
+
op = {"op": "add", "path": f"/metadata/{TERMS_FIELD}/-", "value": new_value}
|
|
94
|
+
else:
|
|
95
|
+
op = {"op": "add", "path": f"/metadata/{TERMS_FIELD}", "value": [new_value]}
|
|
96
|
+
logger.info("Setting terms of use (%s) via %s", language, op["op"])
|
|
97
|
+
return self._request("PATCH", self._url(SITES, f"/{site['uuid']}"), "Set terms of use",
|
|
98
|
+
headers=self._headers(), json=[op]).json()
|