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 ADDED
@@ -0,0 +1,75 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """DSpace API toolkit"""
3
+ from ._version import __version__
4
+ from .config import DSpaceConfig
5
+ from .auth import DSpaceAuth
6
+ from .items import ItemManager
7
+ from .workspaces import WorkspaceManager
8
+ from .bundles import BundleManager
9
+ from .collections import CollectionManager
10
+ from .discovery import DiscoveryManager
11
+ from .vocabulary import VocabularyManager
12
+ from .permissions import PermissionManager
13
+ from .communities import CommunityManager
14
+ from .groups import GroupManager
15
+ from .epersons import EPersonManager
16
+ from .site import SiteManager
17
+ from .entity_types import EntityTypeManager
18
+ from .metadata import MetadataManager, MetadataPatch
19
+ from .authorizations import AuthorizationManager, Feature
20
+ from .versions import VersionManager
21
+ from .relationships import RelationshipManager
22
+ from .workflow import WorkflowError, WorkflowManager
23
+ from .workspaces import SubmissionError
24
+
25
+
26
+ class DSpaceClient:
27
+ """Client for interacting with the DSpace REST API."""
28
+ def __init__(self, dspace_url=None, username=None, password=None, version=8, all_languages=None):
29
+ """Initialize the DSpace client with optional URL, credentials, API version, and language settings.
30
+
31
+ Parameters:
32
+ dspace_url: The base URL of the DSpace instance.
33
+ username: The username for authentication.
34
+ password: The password for authentication.
35
+ version: The API version to use (default is 8).
36
+ all_languages: Optional flag for handling all languages in metadata.
37
+
38
+ """
39
+ # all_languages: default for projection=allLanguages on reads
40
+ # (None -> DSPACE_ALL_LANGUAGES env var, default off)
41
+ self.config = DSpaceConfig(dspace_url, version, all_languages=all_languages)
42
+ # Initialize authentication manager with the given configuration.
43
+ self.auth = DSpaceAuth(self.config)
44
+ # Log in if username and password are provided.
45
+ if username and password:
46
+ self.auth.login(username, password)
47
+ # Initialize managers for various DSpace API components.
48
+ self.items = ItemManager(self.auth)
49
+ self.workspace = WorkspaceManager(self.auth)
50
+ self.bundles = BundleManager(self.auth)
51
+ self.collections = CollectionManager(self.auth)
52
+ self.discovery = DiscoveryManager(self.auth)
53
+ self.vocabulary = VocabularyManager(self.auth)
54
+ self.permissions = PermissionManager(self.auth)
55
+ self.communities = CommunityManager(self.auth)
56
+ self.groups = GroupManager(self.auth)
57
+ self.epersons = EPersonManager(self.auth)
58
+ self.site = SiteManager(self.auth)
59
+ self.entity_types = EntityTypeManager(self.auth)
60
+ self.metadata = MetadataManager(self.auth)
61
+ self.authorizations = AuthorizationManager(self.auth)
62
+ self.versions = VersionManager(self.auth)
63
+ self.relationships = RelationshipManager(self.auth)
64
+ self.workflow = WorkflowManager(self.auth)
65
+
66
+ def close(self):
67
+ """Close the client and log out from the DSpace API.
68
+
69
+ Parameters:
70
+ None
71
+
72
+ Returns:
73
+ None
74
+ """
75
+ self.auth.logout()
dspace_api/_base.py ADDED
@@ -0,0 +1,407 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ Shared request handling for DSpace API managers.
4
+
5
+ Every manager that talks to the REST API through ``session.request()``
6
+ inherits from BaseManager, so they all get the same behaviour:
7
+
8
+ - the session is always the auth object's 'current' one
9
+ (DSpaceAuth.logout() replaces it)
10
+ - CSRF tokens returned by DSpace are stored after every response; retries,
11
+ token refresh, re-login and the CSRF retry live in DSpaceSession
12
+ - HAL collections are paged transparently
13
+ - links from ``_links.*.href`` can be followed, but only on the
14
+ configured DSpace server (the session carries the auth header)
15
+ - GET requests take ``projection=`` and ``embed=`` (see projection.py),
16
+ and get ``projection=allLanguages`` when enabled - per call
17
+ (``all_languages=True/False``), in a ``config.languages()`` block, or
18
+ as the config default (``DSpaceConfig(all_languages=...)`` /
19
+ ``DSPACE_ALL_LANGUAGES``)
20
+ """
21
+
22
+ import logging
23
+ from typing import Any, Dict, Iterator, Optional, Sequence, Tuple
24
+
25
+ from .projection import EmbedArg, ProjectionArg, view_params
26
+ from .exceptions import handle_response_error
27
+ from .utils import extract_uuid_from_url, validate_uuid
28
+
29
+ # Logging setup for the module
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Constants for DSpace API headers
33
+
34
+ # Header for the DSpace XSRF token returned in responses
35
+ XSRF_RESPONSE_HEADER = "DSPACE-XSRF-TOKEN"
36
+
37
+
38
+ class BaseManager:
39
+ """Base class with request, paging and link helpers"""
40
+
41
+ DEFAULT_PAGE_SIZE = 100
42
+
43
+ def __init__(self, auth):
44
+ """Store the authentication manager and configuration.
45
+
46
+ Parameters:
47
+ auth: The authentication manager instance.
48
+ """
49
+ self.auth = auth
50
+ self.config = auth.config
51
+
52
+ @property
53
+ def session(self):
54
+ """Return the current session from the authentication manager.
55
+
56
+ Returns:
57
+ The current session object.
58
+ """
59
+ return self.auth.session
60
+
61
+ # ------------------------------------------------------------------
62
+ # URLs and headers
63
+ # ------------------------------------------------------------------
64
+
65
+ def _url(self, *parts: str) -> str:
66
+ """Construct a full URL by joining the base DSpace URL with the given path parts.
67
+
68
+ Parameters:
69
+ *parts: The path parts to join with the base DSpace URL.
70
+
71
+ Returns:
72
+ The full URL as a string.
73
+ """
74
+ return self.config.dspace_url + "".join(str(p) for p in parts)
75
+
76
+ # A uri-list body is just the absolute URL of the referenced resource
77
+ _uri_list = _url
78
+
79
+ def _headers(self, content_type: Optional[str] = "application/json", accept: str = "application/json"):
80
+ """Construct the headers for a request, including content type and accept headers.
81
+
82
+ Parameters:
83
+ content_type: The value for the 'Content-Type' header, or None to omit it.
84
+ accept: The value for the 'Accept' header.
85
+
86
+ Returns:
87
+ A dictionary of headers for the request.
88
+ """
89
+ headers = {k: v for k, v in self.config.headers.items() if k.lower() not in ("content-type", "accept")}
90
+ headers["Accept"] = accept
91
+ if content_type:
92
+ headers["Content-Type"] = content_type
93
+ return headers
94
+
95
+ def resolve_href(self, href: str) -> str:
96
+ """Turn a HAL href or an '/api/...' path into an absolute URL on the
97
+ configured server. Absolute URLs on other hosts are refused, because
98
+ the session would send the Authorization header along.
99
+
100
+ Parameters:
101
+ href: The HAL href or '/api/...' path to resolve.
102
+
103
+ Returns:
104
+ The absolute URL on the configured server.
105
+ """
106
+ if href.startswith(("http://", "https://")):
107
+ if not href.startswith(self.config.dspace_url):
108
+ raise ValueError(f"Refusing to follow {href!r}: not under {self.config.dspace_url}")
109
+ return href
110
+ if href.startswith("/api/") and self.config.dspace_url.endswith("/api"):
111
+ return self.config.dspace_url[: -len("/api")] + href
112
+ return self._url("/" + href.lstrip("/"))
113
+
114
+ # ------------------------------------------------------------------
115
+ # Requests
116
+ # ------------------------------------------------------------------
117
+
118
+ def _update_token(self, response) -> None:
119
+ """Update the authentication token based on the response, if applicable."""
120
+ update = getattr(self.auth, "_update_token", None)
121
+ if update:
122
+ update(response)
123
+
124
+ def _request(self, method: str, url: str, operation: str, expected=(200,),
125
+ all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None, **kwargs):
126
+ """Perform an HTTP request with the given method, URL, and parameters.
127
+ Handles projection, embedding, and language settings for GET requests.
128
+
129
+ Parameters:
130
+ method: The HTTP method to use (e.g., "GET", "POST").
131
+ url: The URL to send the request to.
132
+ operation: A description of the operation for logging or error messages.
133
+ expected: A tuple of expected HTTP status codes.
134
+ all_languages: Whether to retrieve all language variants (for GET requests).
135
+ projection: Optional HAL projection parameter (for GET requests).
136
+ embed: Optional HAL embed parameter (for GET requests).
137
+ **kwargs: Additional arguments to pass to the request.
138
+
139
+ Returns:
140
+ The HTTP response object.
141
+
142
+ """
143
+ if method.upper() == "GET":
144
+ kwargs["params"] = view_params(kwargs.get("params"), self.config, all_languages, projection, embed)
145
+ elif projection or embed:
146
+ raise ValueError("projection/embed only apply to GET requests")
147
+ kwargs.setdefault("headers", self.config.headers)
148
+ kwargs.setdefault("timeout", self.config.timeout)
149
+ # Retries, token refresh, re-login and the CSRF retry happen in
150
+ # DSpaceSession (see session.py), so every manager gets them.
151
+ response = self.session.request(method, url, **kwargs)
152
+ self._update_token(response)
153
+
154
+ if response.status_code not in expected:
155
+ logger.error("%s failed: HTTP %s %s", operation, response.status_code, response.text[:500])
156
+ handle_response_error(response, operation)
157
+ return response
158
+
159
+ def _get_json(self, url: str, operation: str, params=None,
160
+ all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> Dict[str, Any]:
161
+ """Perform a GET request and return the JSON response as a dictionary.
162
+
163
+ Parameters:
164
+ url: The URL to send the GET request to.
165
+ operation: A description of the operation for logging or error messages.
166
+ params: Optional query parameters as a dictionary.
167
+ all_languages: Whether to retrieve all language variants.
168
+ projection: Optional HAL projection parameter.
169
+ embed: Optional HAL embed parameter.
170
+
171
+ Returns:
172
+ The JSON response as a dictionary.
173
+ """
174
+ return self._request("GET", url, operation, params=params, all_languages=all_languages,
175
+ projection=projection, embed=embed).json()
176
+
177
+ def _get_optional(self, url: str, operation: str, params=None,
178
+ all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> Optional[Dict[str, Any]]:
179
+ """Perform a GET request that maps '204 No Content' (and an empty 200) to None.
180
+
181
+ Parameters:
182
+ url: The URL to send the GET request to.
183
+ operation: A description of the operation for logging or error messages.
184
+ params: Optional query parameters as a dictionary.
185
+ all_languages: Whether to retrieve all language variants.
186
+ projection: Optional HAL projection parameter.
187
+ embed: Optional HAL embed parameter.
188
+
189
+ Returns:
190
+ The JSON response as a dictionary, or None if there is no content.
191
+ """
192
+ response = self._request("GET", url, operation, expected=(200, 204), params=params,
193
+ all_languages=all_languages, projection=projection, embed=embed)
194
+ if response.status_code == 204 or not response.content:
195
+ return None
196
+ return response.json() or None
197
+
198
+ @staticmethod
199
+ def _json_or_none(response) -> Optional[Dict[str, Any]]:
200
+ """Return the JSON content of the response, or None if there is no content.
201
+
202
+ Parameters:
203
+ response: The HTTP response object.
204
+
205
+ Returns:
206
+ The JSON content as a dictionary, or None if there is no content.
207
+ """
208
+ return response.json() if response.content else None
209
+
210
+ def _iter_embedded(self, url: str, key, operation: str,
211
+ params: Optional[Sequence[Tuple[str, Any]]] = None,
212
+ page_size: Optional[int] = None,
213
+ max_results: Optional[int] = None,
214
+ all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> Iterator[Dict[str, Any]]:
215
+ """
216
+ Yield every object of a paginated HAL collection (_embedded.<key>).
217
+ key may be a tuple of alternative names (first one present wins).
218
+ params is a list of tuples so repeated keys (embed=a&embed=b) work.
219
+
220
+ Parameters:
221
+ url: The URL of the HAL collection.
222
+ key: The key or tuple of alternative keys to look for in the _embedded section.
223
+ operation: A description of the operation for logging or error messages.
224
+ params: Optional query parameters as a list of key-value pairs.
225
+ page_size: The number of results per page.
226
+ max_results: The maximum number of results to yield.
227
+ all_languages: Whether to retrieve all language variants.
228
+ projection: Optional HAL projection parameter.
229
+ embed: Optional HAL embed parameter.
230
+
231
+ Yields:
232
+ Each object in the paginated HAL collection.
233
+ """
234
+ keys = (key,) if isinstance(key, str) else tuple(key)
235
+ size = page_size or self.DEFAULT_PAGE_SIZE
236
+ page = 0
237
+ yielded = 0
238
+ while True:
239
+ p = list(params or []) + [("page", page), ("size", size)]
240
+ data = self._request("GET", url, operation, params=p, all_languages=all_languages,
241
+ projection=projection, embed=embed).json()
242
+ embedded = data.get("_embedded", {})
243
+ objects = next((embedded[k] for k in keys if k in embedded), [])
244
+ for obj in objects:
245
+ yield obj
246
+ yielded += 1
247
+ if max_results is not None and yielded >= max_results:
248
+ return
249
+ total_pages = (data.get("page") or {}).get("totalPages")
250
+ if not objects:
251
+ break
252
+ if total_pages is not None and page + 1 >= total_pages:
253
+ break
254
+ if total_pages is None and len(objects) < size:
255
+ break
256
+ page += 1
257
+
258
+ def _discover(self, query: str = "", dso_type: Optional[str] = None, configuration: Optional[str] = None,
259
+ scope: Optional[str] = None, sort: Optional[str] = None, size: int = 20,
260
+ max_results: Optional[int] = None, all_languages: Optional[bool] = None,
261
+ projection: ProjectionArg = None, embed: EmbedArg = None):
262
+ """Perform a discovery search returning the indexableObjects (all pages up to max_results).
263
+
264
+ Parameters:
265
+ query: The search query string.
266
+ dso_type: The type of DSpace object to search for.
267
+ configuration: The discovery configuration to use.
268
+ scope: The scope of the search.
269
+ sort: The sort order of the results.
270
+ size: The number of results per page.
271
+ max_results: The maximum number of results to return.
272
+ all_languages: Whether to retrieve all language variants.
273
+ projection: Optional HAL projection parameter.
274
+ embed: Optional HAL embed parameter.
275
+
276
+ Returns:
277
+ A list of indexable DSpace objects matching the search criteria.
278
+ """
279
+ url = self._url("/discover/search/objects")
280
+ page = 0
281
+ found = []
282
+ while True:
283
+ params = [("page", page), ("size", size)]
284
+ for key, value in (("query", query.strip() if query else None), ("dsoType", dso_type),
285
+ ("configuration", configuration), ("scope", scope), ("sort", sort)):
286
+ if value:
287
+ params.append((key, value))
288
+ result = (self._get_json(url, "Discovery search", params, all_languages=all_languages,
289
+ projection=projection, embed=embed)
290
+ .get("_embedded", {}).get("searchResult", {}))
291
+ objects = result.get("_embedded", {}).get("objects", [])
292
+ for obj in objects:
293
+ dso = obj.get("_embedded", {}).get("indexableObject")
294
+ if dso:
295
+ found.append(dso)
296
+ if max_results is not None and len(found) >= max_results:
297
+ return found
298
+ page += 1
299
+ if not objects or page >= (result.get("page") or {}).get("totalPages", 0):
300
+ return found
301
+
302
+ def get_by_href(self, href: str, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> Dict[str, Any]:
303
+ """Follow a HAL link, e.g. item['_links']['owningCollection']['href'].
304
+
305
+ Parameters:
306
+ href: The HAL link to follow.
307
+ all_languages: Whether to retrieve all language variants.
308
+ projection: Optional HAL projection parameter.
309
+ embed: Optional HAL embed parameter.
310
+
311
+ Returns:
312
+ The JSON response as a dictionary.
313
+ """
314
+ return self._get_json(self.resolve_href(href), f"GET {href}", all_languages=all_languages,
315
+ projection=projection, embed=embed)
316
+
317
+ # ------------------------------------------------------------------
318
+ # References
319
+ # ------------------------------------------------------------------
320
+
321
+ @staticmethod
322
+ def _uuid_of(ref) -> Optional[str]:
323
+ """
324
+ UUID from a DSpace object dict, a UUID string or a HAL href.
325
+ Returns None when ref is something else (e.g. a name or an email).
326
+
327
+ Parameters:
328
+ ref: The reference to extract the UUID from. Can be a DSpace object dict, a UUID string, or a HAL href.
329
+
330
+ Returns:
331
+ The UUID string if it can be determined, otherwise None.
332
+ """
333
+ if isinstance(ref, dict):
334
+ return ref.get("uuid") or ref.get("id")
335
+ if isinstance(ref, str):
336
+ if validate_uuid(ref):
337
+ return ref
338
+ if ref.startswith(("http://", "https://", "/")):
339
+ return extract_uuid_from_url(ref)
340
+ return None
341
+
342
+ @staticmethod
343
+ def _format_metadata_field(values):
344
+ """Normalise a str / dict / list into a DSpace MetadataValue list.
345
+
346
+ Parameters:
347
+ values: The metadata value(s) to normalise.
348
+
349
+ Returns:
350
+ A list of DSpace MetadataValue dictionaries.
351
+ """
352
+ if isinstance(values, str):
353
+ return [{"value": values}]
354
+ if isinstance(values, dict):
355
+ return [values]
356
+ if isinstance(values, list):
357
+ return [v if isinstance(v, dict) else {"value": str(v)} for v in values]
358
+ return [{"value": str(values)}]
359
+
360
+ def _format_metadata(self, metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
361
+ """Format the metadata dictionary into the structure expected by DSpace.
362
+
363
+ Parameters:
364
+ metadata: The metadata dictionary to format.
365
+
366
+ Returns:
367
+ The formatted metadata dictionary.
368
+ """
369
+ return {field: self._format_metadata_field(v) for field, v in (metadata or {}).items()}
370
+
371
+ def _metadata_patch_ops(self, operation: str, metadata: Dict[str, Any]):
372
+ """Generate JSON Patch operations for modifying metadata.
373
+
374
+ Parameters:
375
+ operation: The JSON Patch operation ('add', 'replace', or 'remove').
376
+ metadata: The metadata dictionary to modify.
377
+
378
+ Returns:
379
+ A list of JSON Patch operations for the given metadata modification.
380
+ """
381
+ if operation not in ("add", "replace", "remove"):
382
+ raise ValueError("operation must be 'add', 'replace' or 'remove'")
383
+ ops = []
384
+ for field, values in metadata.items():
385
+ op = {"op": operation, "path": f"/metadata/{field}"}
386
+ if operation != "remove":
387
+ op["value"] = self._format_metadata_field(values)
388
+ ops.append(op)
389
+ return ops
390
+
391
+ @staticmethod
392
+ def _upsert_ops(existing: Dict[str, Any], metadata: Dict[str, Any], formatter):
393
+ """Generate JSON Patch operations to upsert metadata fields.
394
+
395
+ For fields that already exist in the object, use 'replace'.
396
+ For new fields, use 'add'.
397
+
398
+ Parameters:
399
+ existing: The existing metadata dictionary of the object.
400
+ metadata: The new metadata to upsert.
401
+ formatter: A function to format the metadata values.
402
+
403
+ Returns:
404
+ A list of JSON Patch operations for upserting the metadata.
405
+ """
406
+ return [{"op": "replace" if existing.get(f) else "add", "path": f"/metadata/{f}", "value": formatter(v)}
407
+ for f, v in metadata.items()]