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/bundles.py
ADDED
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Bundle and bitstream management for DSpace API (DSpace 7+ / DSpace-CRIS 7+)
|
|
4
|
+
|
|
5
|
+
Bundles
|
|
6
|
+
GET /core/items/{itemUuid}/bundles list bundles of an item
|
|
7
|
+
POST /core/items/{itemUuid}/bundles create a bundle
|
|
8
|
+
GET /core/bundles/{uuid} get a bundle
|
|
9
|
+
PATCH /core/bundles/{uuid} metadata / re-order bitstreams
|
|
10
|
+
DELETE /core/bundles/{uuid} delete bundle (and ALL its bitstreams)
|
|
11
|
+
GET /core/bundles/{uuid}/bitstreams list bitstreams (paginated)
|
|
12
|
+
POST /core/bundles/{uuid}/bitstreams upload a bitstream (multipart)
|
|
13
|
+
GET|POST|PUT|DELETE /core/bundles/{uuid}/primaryBitstream
|
|
14
|
+
|
|
15
|
+
Bitstreams
|
|
16
|
+
GET /core/bitstreams/{uuid} get a bitstream
|
|
17
|
+
PATCH /core/bitstreams/{uuid} metadata
|
|
18
|
+
DELETE /core/bitstreams/{uuid} delete one bitstream
|
|
19
|
+
PATCH /core/bitstreams delete several bitstreams
|
|
20
|
+
GET /core/bitstreams/{uuid}/content download
|
|
21
|
+
GET|PUT /core/bitstreams/{uuid}/format read / set bitstream format
|
|
22
|
+
GET|PUT /core/bitstreams/{uuid}/bundle read / move to another bundle
|
|
23
|
+
|
|
24
|
+
Bitstream formats (registry)
|
|
25
|
+
GET /core/bitstreamformats list formats
|
|
26
|
+
POST /core/bitstreamformats create (admin)
|
|
27
|
+
GET|PUT|DELETE /core/bitstreamformats/{id} read / update / delete (admin)
|
|
28
|
+
|
|
29
|
+
Notes
|
|
30
|
+
-----
|
|
31
|
+
- Every request goes through ``_request()``, which feeds the response back to
|
|
32
|
+
``DSpaceAuth._update_token()``. DSpace rotates the CSRF token on some
|
|
33
|
+
responses; without this, long batch runs can start failing with 403.
|
|
34
|
+
- Uploads are streamed: the multipart/form-data body is generated on the fly
|
|
35
|
+
from the file (``_MultipartStream``) with an exact Content-Length, so
|
|
36
|
+
requests never switches to chunked transfer encoding (which DSpace does not
|
|
37
|
+
accept reliably) and nothing is read into memory - multi-GB files upload
|
|
38
|
+
with constant memory. MD5 and size are computed while sending, so the file
|
|
39
|
+
is read exactly once, and checked against DSpace's checksum afterwards.
|
|
40
|
+
Non-seekable streams are first spooled to a temporary file (their size must
|
|
41
|
+
be known before sending).
|
|
42
|
+
- Large uploads also need the server side to allow them: DSpace's
|
|
43
|
+
``spring.servlet.multipart.max-file-size`` / ``max-request-size``
|
|
44
|
+
(config/application.properties, default 512MB), the reverse proxy
|
|
45
|
+
(nginx ``client_max_body_size``, ``proxy_request_buffering off``,
|
|
46
|
+
``proxy_read_timeout``) and the Tomcat connector timeout.
|
|
47
|
+
- DSpace guesses the bitstream format from the file extension, not from the
|
|
48
|
+
multipart part's Content-Type. Use ``set_bitstream_format_by_mimetype()``
|
|
49
|
+
(or ``upload_bitstream(..., enforce_mime_format=True)``) if the guess is wrong.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
import hashlib
|
|
53
|
+
import json
|
|
54
|
+
import logging
|
|
55
|
+
import mimetypes
|
|
56
|
+
import os
|
|
57
|
+
import shutil
|
|
58
|
+
import tempfile
|
|
59
|
+
import uuid as uuid_lib
|
|
60
|
+
from typing import Any, BinaryIO, Callable, Dict, Iterator, List, Optional, Tuple, Union
|
|
61
|
+
|
|
62
|
+
from ._base import BaseManager
|
|
63
|
+
from .projection import EmbedArg, ProjectionArg, get_embedded_page, is_embedded_complete
|
|
64
|
+
from .config import Endpoints
|
|
65
|
+
from .exceptions import DSpaceError
|
|
66
|
+
|
|
67
|
+
# DSpace bundle and bitstream management utilities
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
# Standard DSpace bundle names
|
|
71
|
+
ORIGINAL = "ORIGINAL"
|
|
72
|
+
THUMBNAIL = "THUMBNAIL"
|
|
73
|
+
TEXT = "TEXT"
|
|
74
|
+
LICENSE = "LICENSE"
|
|
75
|
+
|
|
76
|
+
BITSTREAM_FORMATS = getattr(Endpoints, "BITSTREAM_FORMATS", "/core/bitstreamformats")
|
|
77
|
+
|
|
78
|
+
# Anything upload_bitstream() accepts as file content
|
|
79
|
+
FileSource = Union[str, "os.PathLike[str]", bytes, bytearray, BinaryIO]
|
|
80
|
+
|
|
81
|
+
# progress(bytes_sent, total_bytes) - called once per chunk while uploading
|
|
82
|
+
ProgressCallback = Callable[[int, int], None]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _format_metadata_field(values) -> List[Dict[str, Any]]:
|
|
86
|
+
"""Normalise a str / list[str] / list[dict] into DSpace MetadataValue list.
|
|
87
|
+
|
|
88
|
+
Parameters:
|
|
89
|
+
values (Union[str, List[str], List[dict], dict]): The metadata values to normalise.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List[Dict[str, Any]]: A list of DSpace MetadataValue dictionaries.
|
|
93
|
+
"""
|
|
94
|
+
if isinstance(values, str):
|
|
95
|
+
return [{"value": values}]
|
|
96
|
+
if isinstance(values, dict):
|
|
97
|
+
return [values]
|
|
98
|
+
if isinstance(values, list):
|
|
99
|
+
out = []
|
|
100
|
+
for v in values:
|
|
101
|
+
out.append(v if isinstance(v, dict) else {"value": str(v)})
|
|
102
|
+
return out
|
|
103
|
+
return [{"value": str(values)}]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _format_metadata(metadata: Optional[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
|
|
107
|
+
"""Normalise a metadata dict into DSpace MetadataValue lists.
|
|
108
|
+
|
|
109
|
+
Parameters:
|
|
110
|
+
metadata (Optional[Dict[str, Any]]): The metadata dictionary to normalise.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Dict[str, List[Dict[str, Any]]]: A dictionary with fields as keys and lists of DSpace MetadataValue dictionaries as values.
|
|
114
|
+
"""
|
|
115
|
+
return {field: _format_metadata_field(v) for field, v in (metadata or {}).items()}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _MultipartStream:
|
|
119
|
+
"""
|
|
120
|
+
multipart/form-data body for one file + one JSON "properties" field,
|
|
121
|
+
produced chunk by chunk from a seekable binary file.
|
|
122
|
+
|
|
123
|
+
requests treats it as a streamed body (it is iterable) of known length
|
|
124
|
+
(``__len__``), so it sends a Content-Length header instead of chunked
|
|
125
|
+
encoding, and urllib3 writes the chunks straight to the socket. The MD5 of
|
|
126
|
+
the file part is computed on the way, so the file is read only once.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, fileobj: BinaryIO, size: int, filename: str, mime_type: str,
|
|
130
|
+
properties: Optional[str], chunk_size: int = 8 * 1024 * 1024,
|
|
131
|
+
progress: Optional[ProgressCallback] = None):
|
|
132
|
+
"""Initialise the multipart stream with the given file, size, filename, MIME type,
|
|
133
|
+
optional JSON properties, chunk size, and progress callback.
|
|
134
|
+
|
|
135
|
+
Parameters:
|
|
136
|
+
fileobj (BinaryIO): The binary file object to read from.
|
|
137
|
+
size (int): The size of the file in bytes.
|
|
138
|
+
filename (str): The name of the file.
|
|
139
|
+
mime_type (str): The MIME type of the file.
|
|
140
|
+
properties (Optional[str]): Optional JSON properties for the multipart request.
|
|
141
|
+
chunk_size (int, optional): The size of each chunk to read. Defaults to 8 * 1024 * 1024.
|
|
142
|
+
progress (Optional[ProgressCallback], optional): A callback for progress updates. Defaults to None.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
None
|
|
146
|
+
"""
|
|
147
|
+
self.boundary = uuid_lib.uuid4().hex
|
|
148
|
+
self.content_type = f"multipart/form-data; boundary={self.boundary}"
|
|
149
|
+
self._file = fileobj
|
|
150
|
+
self._start = fileobj.tell()
|
|
151
|
+
self._size = size
|
|
152
|
+
self._chunk = max(64 * 1024, int(chunk_size))
|
|
153
|
+
self._progress = progress
|
|
154
|
+
safe_name = filename.replace("\\", "\\\\").replace('"', '\\"').replace("\r", " ").replace("\n", " ")
|
|
155
|
+
b = self.boundary
|
|
156
|
+
# properties=None: only the file part (e.g. workspace item uploads)
|
|
157
|
+
properties_part = (
|
|
158
|
+
f"--{b}\r\n"
|
|
159
|
+
'Content-Disposition: form-data; name="properties"\r\n'
|
|
160
|
+
"Content-Type: application/json\r\n\r\n"
|
|
161
|
+
f"{properties}\r\n"
|
|
162
|
+
) if properties is not None else ""
|
|
163
|
+
self._head = (
|
|
164
|
+
properties_part +
|
|
165
|
+
f"--{b}\r\n"
|
|
166
|
+
f'Content-Disposition: form-data; name="file"; filename="{safe_name}"\r\n'
|
|
167
|
+
f"Content-Type: {mime_type}\r\n\r\n"
|
|
168
|
+
).encode("utf-8")
|
|
169
|
+
self._tail = f"\r\n--{b}--\r\n".encode("ascii")
|
|
170
|
+
self.md5 = hashlib.md5() # noqa: S324 - DSpace's checksum algorithm
|
|
171
|
+
self.bytes_read = 0
|
|
172
|
+
|
|
173
|
+
def __len__(self) -> int:
|
|
174
|
+
"""Return the total length of the multipart stream.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
int: The total length of the multipart stream in bytes.
|
|
178
|
+
"""
|
|
179
|
+
return len(self._head) + self._size + len(self._tail)
|
|
180
|
+
|
|
181
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
182
|
+
"""Yield the multipart stream in chunks, updating the MD5 digest and progress callback.
|
|
183
|
+
|
|
184
|
+
Yields:
|
|
185
|
+
bytes: Chunks of the multipart stream.
|
|
186
|
+
"""
|
|
187
|
+
self._file.seek(self._start)
|
|
188
|
+
self.md5 = hashlib.md5() # noqa: S324
|
|
189
|
+
self.bytes_read = 0
|
|
190
|
+
yield self._head
|
|
191
|
+
remaining = self._size
|
|
192
|
+
while remaining > 0:
|
|
193
|
+
chunk = self._file.read(min(self._chunk, remaining))
|
|
194
|
+
if not chunk:
|
|
195
|
+
raise DSpaceError(f"File ended after {self.bytes_read} of {self._size} bytes while uploading")
|
|
196
|
+
remaining -= len(chunk)
|
|
197
|
+
self.bytes_read += len(chunk)
|
|
198
|
+
self.md5.update(chunk)
|
|
199
|
+
if self._progress:
|
|
200
|
+
self._progress(self.bytes_read, self._size)
|
|
201
|
+
yield chunk
|
|
202
|
+
yield self._tail
|
|
203
|
+
|
|
204
|
+
def file_digest(self) -> Tuple[str, int]:
|
|
205
|
+
"""
|
|
206
|
+
Compute the MD5 digest and size of the file part.
|
|
207
|
+
|
|
208
|
+
Uses the digest computed while sending when the whole file went out;
|
|
209
|
+
otherwise (body not consumed by the transport, upload aborted midway)
|
|
210
|
+
re-reads the file once.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Tuple[str, int]: A tuple containing the MD5 hex digest and the size of the file part in bytes.
|
|
214
|
+
"""
|
|
215
|
+
if self.bytes_read == self._size:
|
|
216
|
+
return self.md5.hexdigest(), self.bytes_read
|
|
217
|
+
digest = hashlib.md5() # noqa: S324
|
|
218
|
+
self._file.seek(self._start)
|
|
219
|
+
remaining = self._size
|
|
220
|
+
while remaining > 0:
|
|
221
|
+
chunk = self._file.read(min(self._chunk, remaining))
|
|
222
|
+
if not chunk:
|
|
223
|
+
break
|
|
224
|
+
digest.update(chunk)
|
|
225
|
+
remaining -= len(chunk)
|
|
226
|
+
return digest.hexdigest(), self._size - remaining
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _source_size(fh: BinaryIO) -> Optional[int]:
|
|
230
|
+
"""Bytes from the current position to the end, or None if the stream can't tell.
|
|
231
|
+
|
|
232
|
+
Parameters:
|
|
233
|
+
fh (BinaryIO): The binary file object to check the size of.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Optional[int]: The number of bytes from the current position to the end, or None if unknown.
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
pos = fh.tell()
|
|
240
|
+
try:
|
|
241
|
+
return os.fstat(fh.fileno()).st_size - pos
|
|
242
|
+
except (AttributeError, OSError, ValueError):
|
|
243
|
+
end = fh.seek(0, os.SEEK_END)
|
|
244
|
+
fh.seek(pos)
|
|
245
|
+
return end - pos
|
|
246
|
+
except (AttributeError, OSError, ValueError):
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class _OpenedSource:
|
|
251
|
+
"""A FileSource as (name, seekable binary file, size); closes what it opened."""
|
|
252
|
+
|
|
253
|
+
def __init__(self, source: FileSource, name: Optional[str]):
|
|
254
|
+
"""
|
|
255
|
+
Initialize an opened source from a file source.
|
|
256
|
+
|
|
257
|
+
Parameters:
|
|
258
|
+
source (FileSource): The file source to open.
|
|
259
|
+
name (Optional[str]): The name of the file, if available.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
TypeError: If the source type is unsupported or not opened in binary mode.
|
|
263
|
+
ValueError: If the name is not provided for raw bytes or unnamed streams.
|
|
264
|
+
"""
|
|
265
|
+
self._owned = None
|
|
266
|
+
if isinstance(source, (bytes, bytearray)):
|
|
267
|
+
import io
|
|
268
|
+
self.file = self._owned = io.BytesIO(bytes(source))
|
|
269
|
+
elif isinstance(source, (str, os.PathLike)):
|
|
270
|
+
path = os.fspath(source)
|
|
271
|
+
self.file = self._owned = open(path, "rb")
|
|
272
|
+
name = name or os.path.basename(path)
|
|
273
|
+
elif hasattr(source, "read"):
|
|
274
|
+
self.file = source
|
|
275
|
+
name = name or os.path.basename(getattr(source, "name", "") or "")
|
|
276
|
+
else:
|
|
277
|
+
raise TypeError(f"Unsupported file source type: {type(source).__name__}")
|
|
278
|
+
if not name:
|
|
279
|
+
self.close()
|
|
280
|
+
raise ValueError("A bitstream name is required when uploading raw bytes or an unnamed stream")
|
|
281
|
+
self.name = name
|
|
282
|
+
|
|
283
|
+
size = _source_size(self.file)
|
|
284
|
+
if size is None:
|
|
285
|
+
# Pipe / socket / other non-seekable stream: spool it so its size is known
|
|
286
|
+
spool = tempfile.TemporaryFile()
|
|
287
|
+
first = self.file.read(0)
|
|
288
|
+
if isinstance(first, str):
|
|
289
|
+
spool.close()
|
|
290
|
+
raise TypeError("File-like source must be opened in binary mode ('rb')")
|
|
291
|
+
shutil.copyfileobj(self.file, spool, 8 * 1024 * 1024)
|
|
292
|
+
spool.seek(0)
|
|
293
|
+
self.close()
|
|
294
|
+
self.file = self._owned = spool
|
|
295
|
+
size = _source_size(spool)
|
|
296
|
+
else:
|
|
297
|
+
probe = self.file.read(0)
|
|
298
|
+
if isinstance(probe, str):
|
|
299
|
+
self.close()
|
|
300
|
+
raise TypeError("File-like source must be opened in binary mode ('rb')")
|
|
301
|
+
self.size = size
|
|
302
|
+
|
|
303
|
+
def close(self) -> None:
|
|
304
|
+
"""Close the owned file, if any.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
None
|
|
308
|
+
"""
|
|
309
|
+
if self._owned is not None:
|
|
310
|
+
self._owned.close()
|
|
311
|
+
self._owned = None
|
|
312
|
+
|
|
313
|
+
def __enter__(self):
|
|
314
|
+
"""Enter the runtime context related to this object.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
_OpenedSource: The opened source object itself.
|
|
318
|
+
"""
|
|
319
|
+
return self
|
|
320
|
+
|
|
321
|
+
def __exit__(self, *exc):
|
|
322
|
+
"""Exit the runtime context related to this object, closing the owned file if any."""
|
|
323
|
+
self.close()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class BundleManager(BaseManager):
|
|
327
|
+
"""Manages DSpace bundles, bitstreams and the bitstream format registry"""
|
|
328
|
+
|
|
329
|
+
UPLOAD_TIMEOUT = 300 # seconds; the 30 s config default is too short for large files
|
|
330
|
+
# Lowest throughput assumed when DSpace finishes a large upload (checksum,
|
|
331
|
+
# asset store / S3 copy) before answering: the read timeout grows with size.
|
|
332
|
+
UPLOAD_MIN_BYTES_PER_SECOND = 10 * 1024 * 1024
|
|
333
|
+
UPLOAD_CHUNK_SIZE = 8 * 1024 * 1024
|
|
334
|
+
|
|
335
|
+
def __init__(self, auth):
|
|
336
|
+
"""Initialise the BundleManager with the given authentication.
|
|
337
|
+
|
|
338
|
+
Parameters:
|
|
339
|
+
auth: The authentication object to use for API requests.
|
|
340
|
+
"""
|
|
341
|
+
super().__init__(auth)
|
|
342
|
+
self._format_cache: Optional[List[Dict[str, Any]]] = None
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def _open_source(source: FileSource, name: Optional[str]) -> _OpenedSource:
|
|
346
|
+
"""Open a FileSource for streaming (nothing is read into memory).
|
|
347
|
+
|
|
348
|
+
Parameters:
|
|
349
|
+
source (FileSource): The file source to open.
|
|
350
|
+
name (Optional[str]): The name of the file, if available.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
_OpenedSource: The opened source object.
|
|
354
|
+
|
|
355
|
+
Raises:
|
|
356
|
+
TypeError: If the source type is unsupported or not opened in binary mode.
|
|
357
|
+
ValueError: If the name is not provided for raw bytes or unnamed streams.
|
|
358
|
+
"""
|
|
359
|
+
return _OpenedSource(source, name)
|
|
360
|
+
|
|
361
|
+
@staticmethod
|
|
362
|
+
def _read_source(source: FileSource, name: Optional[str]) -> Tuple[str, bytes]:
|
|
363
|
+
"""Whole content in memory - kept for callers that want bytes; uploads stream instead.
|
|
364
|
+
|
|
365
|
+
Parameters:
|
|
366
|
+
source (FileSource): The file source to read.
|
|
367
|
+
name (Optional[str]): The name of the file, if available.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Tuple[str, bytes]: The name of the file and its content as bytes.
|
|
371
|
+
|
|
372
|
+
Raises:
|
|
373
|
+
TypeError: If the source type is unsupported or not opened in binary mode.
|
|
374
|
+
ValueError: If the name is not provided for raw bytes or unnamed streams.
|
|
375
|
+
"""
|
|
376
|
+
if isinstance(source, (bytes, bytearray)):
|
|
377
|
+
content = bytes(source)
|
|
378
|
+
elif isinstance(source, (str, os.PathLike)):
|
|
379
|
+
path = os.fspath(source)
|
|
380
|
+
with open(path, "rb") as fh:
|
|
381
|
+
content = fh.read()
|
|
382
|
+
name = name or os.path.basename(path)
|
|
383
|
+
elif hasattr(source, "read"):
|
|
384
|
+
content = source.read()
|
|
385
|
+
if isinstance(content, str):
|
|
386
|
+
raise TypeError("File-like source must be opened in binary mode ('rb')")
|
|
387
|
+
name = name or os.path.basename(getattr(source, "name", "") or "")
|
|
388
|
+
else:
|
|
389
|
+
raise TypeError(f"Unsupported file source type: {type(source).__name__}")
|
|
390
|
+
|
|
391
|
+
if not name:
|
|
392
|
+
raise ValueError("A bitstream name is required when uploading raw bytes or an unnamed stream")
|
|
393
|
+
return name, content
|
|
394
|
+
|
|
395
|
+
# ------------------------------------------------------------------
|
|
396
|
+
# Bundles
|
|
397
|
+
# ------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
def get_item_bundles(self, item_uuid: str, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> List[Dict[str, Any]]:
|
|
400
|
+
"""Return all bundles of an item (all pages).
|
|
401
|
+
|
|
402
|
+
Parameters:
|
|
403
|
+
item_uuid (str): The UUID of the item.
|
|
404
|
+
all_languages (Optional[bool]): Whether to include all languages.
|
|
405
|
+
projection (ProjectionArg): The projection to use.
|
|
406
|
+
embed (EmbedArg): The embed parameter to use.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
List[Dict[str, Any]]: A list of bundle dictionaries.
|
|
410
|
+
"""
|
|
411
|
+
logger.info("Getting bundles of item %s", item_uuid)
|
|
412
|
+
url = self._url(Endpoints.ITEMS, f"/{item_uuid}/bundles")
|
|
413
|
+
return list(self._iter_embedded(url, "bundles", f"Get bundles of item {item_uuid}",
|
|
414
|
+
all_languages=all_languages, projection=projection, embed=embed))
|
|
415
|
+
|
|
416
|
+
# Page size asked for embedded bundles / bitstreams; larger lists are paged in
|
|
417
|
+
EMBED_PAGE_SIZE = 100
|
|
418
|
+
|
|
419
|
+
def get_item_with_files(self, item_uuid: str, all_languages: Optional[bool] = None
|
|
420
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
421
|
+
"""
|
|
422
|
+
(item, bundles) in one request where possible; each bundle dict gets a
|
|
423
|
+
complete "bitstreams" list.
|
|
424
|
+
|
|
425
|
+
Uses embed=bundles/bitstreams. Embedded lists are pages: if one is
|
|
426
|
+
truncated (more bundles/bitstreams than the embed size), the rest is
|
|
427
|
+
fetched with the paging endpoints, so the result is always complete.
|
|
428
|
+
"""
|
|
429
|
+
item = self._get_json(
|
|
430
|
+
self._url(Endpoints.ITEMS, f"/{item_uuid}"), f"Get item {item_uuid} with files",
|
|
431
|
+
all_languages=all_languages,
|
|
432
|
+
embed={"bundles": self.EMBED_PAGE_SIZE, "bundles/bitstreams": self.EMBED_PAGE_SIZE},
|
|
433
|
+
)
|
|
434
|
+
bundles, _ = get_embedded_page(item, "bundles")
|
|
435
|
+
if bundles is None or not is_embedded_complete(item, "bundles"):
|
|
436
|
+
logger.debug("Embedded bundles of %s missing or truncated - paging", item_uuid)
|
|
437
|
+
bundles = self.get_item_bundles(item_uuid, all_languages=all_languages)
|
|
438
|
+
result = []
|
|
439
|
+
for bundle in bundles:
|
|
440
|
+
bitstreams, _ = get_embedded_page(bundle, "bitstreams")
|
|
441
|
+
if bitstreams is None or not is_embedded_complete(bundle, "bitstreams"):
|
|
442
|
+
bitstreams = self.get_bundle_bitstreams(bundle["uuid"], all_languages=all_languages)
|
|
443
|
+
result.append(dict(bundle, bitstreams=bitstreams))
|
|
444
|
+
return item, result
|
|
445
|
+
|
|
446
|
+
def find_bundle(self, item_uuid: str, name: str = ORIGINAL) -> Optional[Dict[str, Any]]:
|
|
447
|
+
"""Return the first bundle of an item with the given name, or None.
|
|
448
|
+
|
|
449
|
+
Parameters:
|
|
450
|
+
item_uuid (str): The UUID of the item.
|
|
451
|
+
name (str): The name of the bundle to find.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
Optional[Dict[str, Any]]: The first matching bundle dictionary, or None if not found.
|
|
455
|
+
"""
|
|
456
|
+
for bundle in self.get_item_bundles(item_uuid):
|
|
457
|
+
if bundle.get("name") == name:
|
|
458
|
+
return bundle
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
def get_bundle(self, bundle_uuid: str, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> Dict[str, Any]:
|
|
462
|
+
"""Return the bundle with the given UUID.
|
|
463
|
+
|
|
464
|
+
Parameters:
|
|
465
|
+
bundle_uuid (str): The UUID of the bundle.
|
|
466
|
+
all_languages (Optional[bool]): Whether to include all languages.
|
|
467
|
+
projection (ProjectionArg): The projection to use.
|
|
468
|
+
embed (EmbedArg): The embed parameter to use.
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
Dict[str, Any]: The bundle dictionary.
|
|
472
|
+
"""
|
|
473
|
+
logger.info("Getting bundle %s", bundle_uuid)
|
|
474
|
+
url = self._url(Endpoints.BUNDLES, f"/{bundle_uuid}")
|
|
475
|
+
return self._get_json(url, f"Get bundle {bundle_uuid}", all_languages=all_languages, projection=projection, embed=embed)
|
|
476
|
+
|
|
477
|
+
def create_bundle(self, item_uuid: str, name: str = ORIGINAL,
|
|
478
|
+
metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
479
|
+
"""
|
|
480
|
+
Create a bundle on an item (POST /core/items/{uuid}/bundles).
|
|
481
|
+
|
|
482
|
+
DSpace does not enforce unique bundle names, so calling this twice
|
|
483
|
+
creates two bundles with the same name. Use get_or_create_bundle()
|
|
484
|
+
unless you really want that.
|
|
485
|
+
|
|
486
|
+
Parameters:
|
|
487
|
+
item_uuid (str): The UUID of the item.
|
|
488
|
+
name (str): The name of the bundle to create.
|
|
489
|
+
metadata (Optional[Dict[str, Any]]): The metadata for the bundle.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Dict[str, Any]: The created bundle dictionary.
|
|
493
|
+
"""
|
|
494
|
+
logger.info("Creating bundle '%s' on item %s", name, item_uuid)
|
|
495
|
+
url = self._url(Endpoints.ITEMS, f"/{item_uuid}/bundles")
|
|
496
|
+
body = {"name": name, "metadata": _format_metadata(metadata)}
|
|
497
|
+
bundle = self._request(
|
|
498
|
+
"POST", url, f"Create bundle '{name}' on item {item_uuid}",
|
|
499
|
+
expected=(200, 201), headers=self._headers(), json=body,
|
|
500
|
+
).json()
|
|
501
|
+
logger.info("Created bundle %s ('%s')", bundle.get("uuid"), name)
|
|
502
|
+
return bundle
|
|
503
|
+
|
|
504
|
+
def get_or_create_bundle(self, item_uuid: str, name: str = ORIGINAL,
|
|
505
|
+
metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
506
|
+
"""Return the item's bundle with this name, creating it if missing.
|
|
507
|
+
|
|
508
|
+
Parameters:
|
|
509
|
+
item_uuid (str): The UUID of the item.
|
|
510
|
+
name (str): The name of the bundle to find or create.
|
|
511
|
+
metadata (Optional[Dict[str, Any]]): The metadata for the bundle if it needs to be created.
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
Dict[str, Any]: The bundle dictionary.
|
|
515
|
+
"""
|
|
516
|
+
bundle = self.find_bundle(item_uuid, name)
|
|
517
|
+
if bundle:
|
|
518
|
+
logger.info("Using existing bundle %s ('%s')", bundle.get("uuid"), name)
|
|
519
|
+
return bundle
|
|
520
|
+
return self.create_bundle(item_uuid, name, metadata)
|
|
521
|
+
|
|
522
|
+
def patch_bundle(self, bundle_uuid: str, operations: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
523
|
+
"""Apply raw JSON Patch operations to a bundle.
|
|
524
|
+
|
|
525
|
+
Parameters:
|
|
526
|
+
bundle_uuid (str): The UUID of the bundle.
|
|
527
|
+
operations (List[Dict[str, Any]]): The list of JSON Patch operations to apply.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
Dict[str, Any]: The updated bundle dictionary.
|
|
531
|
+
"""
|
|
532
|
+
url = self._url(Endpoints.BUNDLES, f"/{bundle_uuid}")
|
|
533
|
+
response = self._request(
|
|
534
|
+
"PATCH", url, f"Patch bundle {bundle_uuid}", expected=(200, 204),
|
|
535
|
+
headers=self._headers("application/json-patch+json"), json=operations,
|
|
536
|
+
)
|
|
537
|
+
return response.json() if response.status_code == 200 and response.content else self.get_bundle(bundle_uuid)
|
|
538
|
+
|
|
539
|
+
def move_bitstream_in_bundle(self, bundle_uuid: str, from_index: int, to_index: int) -> Dict[str, Any]:
|
|
540
|
+
"""Re-order bitstreams inside a bundle (0-based positions).
|
|
541
|
+
|
|
542
|
+
Parameters:
|
|
543
|
+
bundle_uuid (str): The UUID of the bundle.
|
|
544
|
+
from_index (int): The current index of the bitstream.
|
|
545
|
+
to_index (int): The target index of the bitstream.
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
Dict[str, Any]: The updated bundle dictionary.
|
|
549
|
+
"""
|
|
550
|
+
logger.info("Moving bitstream in bundle %s from position %d to %d", bundle_uuid, from_index, to_index)
|
|
551
|
+
return self.patch_bundle(bundle_uuid, [{
|
|
552
|
+
"op": "move",
|
|
553
|
+
"from": f"/_links/bitstreams/{from_index}/href",
|
|
554
|
+
"path": f"/_links/bitstreams/{to_index}/href",
|
|
555
|
+
}])
|
|
556
|
+
|
|
557
|
+
def delete_bundle(self, bundle_uuid: str) -> bool:
|
|
558
|
+
"""Delete a bundle and all its bitstreams.
|
|
559
|
+
|
|
560
|
+
Parameters:
|
|
561
|
+
bundle_uuid (str): The UUID of the bundle.
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
bool: True if the bundle was deleted successfully.
|
|
565
|
+
"""
|
|
566
|
+
logger.info("Deleting bundle %s (including its bitstreams)", bundle_uuid)
|
|
567
|
+
url = self._url(Endpoints.BUNDLES, f"/{bundle_uuid}")
|
|
568
|
+
self._request("DELETE", url, f"Delete bundle {bundle_uuid}", expected=(204,))
|
|
569
|
+
return True
|
|
570
|
+
|
|
571
|
+
# ------------------------------------------------------------------
|
|
572
|
+
# Primary bitstream
|
|
573
|
+
# ------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
def get_primary_bitstream(self, bundle_uuid: str) -> Optional[Dict[str, Any]]:
|
|
576
|
+
"""Return the primary bitstream of a bundle, or None if none is set.
|
|
577
|
+
|
|
578
|
+
Parameters:
|
|
579
|
+
bundle_uuid (str): The UUID of the bundle.
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
Optional[Dict[str, Any]]: The primary bitstream dictionary, or None if not set.
|
|
583
|
+
"""
|
|
584
|
+
url = self._url(Endpoints.BUNDLES, f"/{bundle_uuid}/primaryBitstream")
|
|
585
|
+
response = self._request("GET", url, f"Get primary bitstream of bundle {bundle_uuid}", expected=(200, 204))
|
|
586
|
+
if response.status_code == 204 or not response.content:
|
|
587
|
+
return None
|
|
588
|
+
return response.json() or None
|
|
589
|
+
|
|
590
|
+
def set_primary_bitstream(self, bundle_uuid: str, bitstream_uuid: str) -> Dict[str, Any]:
|
|
591
|
+
"""Mark a bitstream as primary (POST if none yet, PUT to change it).
|
|
592
|
+
|
|
593
|
+
Parameters:
|
|
594
|
+
bundle_uuid (str): The UUID of the bundle.
|
|
595
|
+
bitstream_uuid (str): The UUID of the bitstream to set as primary.
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
Dict[str, Any]: The updated bundle dictionary.
|
|
599
|
+
"""
|
|
600
|
+
logger.info("Setting primary bitstream of bundle %s to %s", bundle_uuid, bitstream_uuid)
|
|
601
|
+
url = self._url(Endpoints.BUNDLES, f"/{bundle_uuid}/primaryBitstream")
|
|
602
|
+
has_primary = self.get_primary_bitstream(bundle_uuid) is not None
|
|
603
|
+
method, expected = ("PUT", (200,)) if has_primary else ("POST", (200, 201))
|
|
604
|
+
response = self._request(
|
|
605
|
+
method, url, f"Set primary bitstream of bundle {bundle_uuid}", expected=expected,
|
|
606
|
+
headers=self._headers("text/uri-list"),
|
|
607
|
+
data=self._uri_list(Endpoints.BITSTREAMS, f"/{bitstream_uuid}"),
|
|
608
|
+
)
|
|
609
|
+
return response.json() if response.content else self.get_bundle(bundle_uuid)
|
|
610
|
+
|
|
611
|
+
def clear_primary_bitstream(self, bundle_uuid: str) -> bool:
|
|
612
|
+
"""Unset the primary bitstream (the bitstream itself is kept). Returns False if none was set.
|
|
613
|
+
|
|
614
|
+
Parameters:
|
|
615
|
+
bundle_uuid (str): The UUID of the bundle.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
bool: True if the primary bitstream was cleared, False if none was set.
|
|
619
|
+
"""
|
|
620
|
+
if self.get_primary_bitstream(bundle_uuid) is None:
|
|
621
|
+
return False
|
|
622
|
+
url = self._url(Endpoints.BUNDLES, f"/{bundle_uuid}/primaryBitstream")
|
|
623
|
+
self._request("DELETE", url, f"Clear primary bitstream of bundle {bundle_uuid}", expected=(204,))
|
|
624
|
+
return True
|
|
625
|
+
|
|
626
|
+
# ------------------------------------------------------------------
|
|
627
|
+
# Bitstreams inside a bundle
|
|
628
|
+
# ------------------------------------------------------------------
|
|
629
|
+
|
|
630
|
+
def get_bundle_bitstreams(self, bundle_uuid: str, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> List[Dict[str, Any]]:
|
|
631
|
+
"""Return all bitstreams of a bundle (all pages).
|
|
632
|
+
|
|
633
|
+
Parameters:
|
|
634
|
+
bundle_uuid (str): The UUID of the bundle.
|
|
635
|
+
all_languages (Optional[bool]): Whether to include all languages.
|
|
636
|
+
projection (ProjectionArg): The projection to use.
|
|
637
|
+
embed (EmbedArg): The embed parameter to use.
|
|
638
|
+
|
|
639
|
+
Returns:
|
|
640
|
+
List[Dict[str, Any]]: A list of bitstream dictionaries.
|
|
641
|
+
"""
|
|
642
|
+
url = self._url(Endpoints.BUNDLES, f"/{bundle_uuid}/bitstreams")
|
|
643
|
+
return list(self._iter_embedded(url, "bitstreams", f"Get bitstreams of bundle {bundle_uuid}",
|
|
644
|
+
all_languages=all_languages, projection=projection, embed=embed))
|
|
645
|
+
|
|
646
|
+
def find_bitstreams_by_name(self, bundle_uuid: str, name: str) -> List[Dict[str, Any]]:
|
|
647
|
+
"""All bitstreams in the bundle with exactly this name (DSpace allows duplicates).
|
|
648
|
+
|
|
649
|
+
Parameters:
|
|
650
|
+
bundle_uuid (str): The UUID of the bundle.
|
|
651
|
+
name (str): The name of the bitstreams to find.
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
List[Dict[str, Any]]: A list of bitstream dictionaries with the specified name.
|
|
655
|
+
"""
|
|
656
|
+
return [b for b in self.get_bundle_bitstreams(bundle_uuid) if b.get("name") == name]
|
|
657
|
+
|
|
658
|
+
def upload_bitstream(
|
|
659
|
+
self,
|
|
660
|
+
bundle_uuid: str,
|
|
661
|
+
source: FileSource,
|
|
662
|
+
name: Optional[str] = None,
|
|
663
|
+
mime_type: Optional[str] = None,
|
|
664
|
+
description: Optional[str] = None,
|
|
665
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
666
|
+
verify_checksum: bool = True,
|
|
667
|
+
enforce_mime_format: bool = False,
|
|
668
|
+
timeout: Optional[Union[float, Tuple[float, float]]] = None,
|
|
669
|
+
progress: Optional[ProgressCallback] = None,
|
|
670
|
+
chunk_size: Optional[int] = None,
|
|
671
|
+
) -> Dict[str, Any]:
|
|
672
|
+
"""
|
|
673
|
+
Upload a file as a new bitstream into a bundle - streamed, any size.
|
|
674
|
+
|
|
675
|
+
Parameters:
|
|
676
|
+
bundle_uuid (str): The UUID of the target bundle.
|
|
677
|
+
source (FileSource): File path, bytes, or binary file object (read from its
|
|
678
|
+
current position; non-seekable streams are spooled to a temp file).
|
|
679
|
+
name (Optional[str]): Bitstream name (defaults to the file's basename).
|
|
680
|
+
mime_type (Optional[str]): Content-Type of the file part (guessed from name if omitted).
|
|
681
|
+
description (Optional[str]): Convenience for metadata dc.description.
|
|
682
|
+
metadata (Optional[Dict[str, Any]]): Extra bitstream metadata, e.g. {"dc.rights": "open access"}.
|
|
683
|
+
verify_checksum (bool): Compare DSpace's MD5 and size with the ones computed
|
|
684
|
+
while sending; on mismatch the new bitstream is deleted and
|
|
685
|
+
DSpaceError is raised.
|
|
686
|
+
enforce_mime_format (bool): After upload, set the registry format matching
|
|
687
|
+
mime_type if DSpace guessed a different one.
|
|
688
|
+
timeout (Optional[Union[float, Tuple[float, float]]]): Request timeout; default (connect, read) with the read
|
|
689
|
+
timeout scaled to the file size (UPLOAD_MIN_BYTES_PER_SECOND).
|
|
690
|
+
progress (Optional[ProgressCallback]): progress(bytes_sent, total_bytes), called per chunk.
|
|
691
|
+
chunk_size (Optional[int]): Bytes read and sent per chunk (default 8 MiB).
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
dict: the created bitstream
|
|
695
|
+
|
|
696
|
+
A failed upload (connection lost, timeout) may leave a bitstream in
|
|
697
|
+
DSpace if the server had already received the file; check with
|
|
698
|
+
find_bitstreams_by_name() before retrying if that matters.
|
|
699
|
+
"""
|
|
700
|
+
with self._open_source(source, name) as src:
|
|
701
|
+
name = src.name
|
|
702
|
+
mime_type = mime_type or mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
703
|
+
|
|
704
|
+
md = dict(metadata or {})
|
|
705
|
+
if description and "dc.description" not in md:
|
|
706
|
+
md["dc.description"] = description
|
|
707
|
+
properties = {"name": name, "metadata": _format_metadata(md)}
|
|
708
|
+
|
|
709
|
+
body = _MultipartStream(src.file, src.size, name, mime_type, json.dumps(properties),
|
|
710
|
+
chunk_size=chunk_size or self.UPLOAD_CHUNK_SIZE, progress=progress)
|
|
711
|
+
headers = self._headers(content_type=None)
|
|
712
|
+
headers["Content-Type"] = body.content_type
|
|
713
|
+
headers["Content-Length"] = str(len(body))
|
|
714
|
+
|
|
715
|
+
logger.info("Uploading '%s' (%d bytes, %s) to bundle %s", name, src.size, mime_type, bundle_uuid)
|
|
716
|
+
url = self._url(Endpoints.BUNDLES, f"/{bundle_uuid}/bitstreams")
|
|
717
|
+
bitstream = self._request(
|
|
718
|
+
"POST", url, f"Upload bitstream '{name}' to bundle {bundle_uuid}",
|
|
719
|
+
expected=(200, 201),
|
|
720
|
+
headers=headers,
|
|
721
|
+
data=body,
|
|
722
|
+
timeout=timeout or self._upload_timeout(src.size),
|
|
723
|
+
).json()
|
|
724
|
+
bitstream_uuid = bitstream.get("uuid")
|
|
725
|
+
logger.info("Uploaded '%s' as bitstream %s", name, bitstream_uuid)
|
|
726
|
+
|
|
727
|
+
if verify_checksum:
|
|
728
|
+
self._verify_digest(bitstream, *body.file_digest())
|
|
729
|
+
|
|
730
|
+
if enforce_mime_format and mime_type != "application/octet-stream":
|
|
731
|
+
self.set_bitstream_format_by_mimetype(bitstream_uuid, mime_type, only_if_different=True)
|
|
732
|
+
|
|
733
|
+
return bitstream
|
|
734
|
+
|
|
735
|
+
def _upload_timeout(self, size: int) -> Tuple[float, float]:
|
|
736
|
+
"""Calculate the upload timeout based on the file size and configuration.
|
|
737
|
+
Parameters:
|
|
738
|
+
size (int): The size of the file being uploaded in bytes.
|
|
739
|
+
|
|
740
|
+
Returns:
|
|
741
|
+
Tuple[float, float]: A tuple containing the connect and read timeouts.
|
|
742
|
+
"""
|
|
743
|
+
connect = float(getattr(self.config, "timeout", 30) or 30)
|
|
744
|
+
read = max(float(self.UPLOAD_TIMEOUT), size / float(self.UPLOAD_MIN_BYTES_PER_SECOND))
|
|
745
|
+
return connect, read
|
|
746
|
+
|
|
747
|
+
def _verify_upload(self, bitstream: Dict[str, Any], content: bytes) -> None:
|
|
748
|
+
"""Verify against in-memory content (kept for compatibility).
|
|
749
|
+
|
|
750
|
+
Parameters:
|
|
751
|
+
bitstream (Dict[str, Any]): The bitstream metadata returned by DSpace.
|
|
752
|
+
content (bytes): The in-memory content to verify against.
|
|
753
|
+
|
|
754
|
+
Raises:
|
|
755
|
+
DSpaceError: If the verification fails.
|
|
756
|
+
"""
|
|
757
|
+
self._verify_digest(bitstream, hashlib.md5(content).hexdigest(), len(content))
|
|
758
|
+
|
|
759
|
+
def _verify_digest(self, bitstream: Dict[str, Any], local_md5: str, local_size: int) -> None:
|
|
760
|
+
"""Verify the uploaded bitstream against the local MD5 digest and size.
|
|
761
|
+
|
|
762
|
+
Parameters:
|
|
763
|
+
bitstream (Dict[str, Any]): The bitstream metadata returned by DSpace.
|
|
764
|
+
local_md5 (str): The MD5 digest of the local file.
|
|
765
|
+
local_size (int): The size of the local file in bytes.
|
|
766
|
+
|
|
767
|
+
Raises:
|
|
768
|
+
DSpaceError: If the verification fails.
|
|
769
|
+
"""
|
|
770
|
+
checksum = bitstream.get("checkSum") or {}
|
|
771
|
+
remote_md5 = (checksum.get("value") or "").lower()
|
|
772
|
+
remote_size = bitstream.get("sizeBytes")
|
|
773
|
+
algorithm = (checksum.get("checkSumAlgorithm") or "MD5").upper()
|
|
774
|
+
|
|
775
|
+
problems = []
|
|
776
|
+
if remote_size is not None and remote_size != local_size:
|
|
777
|
+
problems.append(f"size {remote_size} != local {local_size}")
|
|
778
|
+
if algorithm == "MD5" and remote_md5 and remote_md5 != local_md5:
|
|
779
|
+
problems.append(f"MD5 {remote_md5} != local {local_md5}")
|
|
780
|
+
|
|
781
|
+
if problems:
|
|
782
|
+
uuid = bitstream.get("uuid")
|
|
783
|
+
logger.error("Upload verification failed for bitstream %s: %s", uuid, "; ".join(problems))
|
|
784
|
+
try:
|
|
785
|
+
if uuid:
|
|
786
|
+
self.delete_bitstream(uuid)
|
|
787
|
+
except Exception as exc: # noqa: BLE001
|
|
788
|
+
logger.error("Could not delete corrupt bitstream %s: %s", uuid, exc)
|
|
789
|
+
raise DSpaceError(f"Upload verification failed for bitstream {uuid}: {'; '.join(problems)}")
|
|
790
|
+
logger.debug("Upload verified (MD5 %s, %d bytes)", local_md5, local_size)
|
|
791
|
+
|
|
792
|
+
def replace_bitstream(self, bundle_uuid: str, source: FileSource, name: Optional[str] = None,
|
|
793
|
+
**upload_kwargs) -> Dict[str, Any]:
|
|
794
|
+
"""
|
|
795
|
+
Replace all bitstreams named `name` in the bundle with a new upload.
|
|
796
|
+
|
|
797
|
+
Upload happens first and old copies are deleted only afterwards, so a
|
|
798
|
+
failed upload never leaves the bundle without the file. If one of the
|
|
799
|
+
old copies was the primary bitstream, the new one becomes primary.
|
|
800
|
+
|
|
801
|
+
Parameters:
|
|
802
|
+
bundle_uuid (str): The UUID of the bundle containing the bitstreams to replace.
|
|
803
|
+
source (FileSource): The source of the new file to upload.
|
|
804
|
+
name (Optional[str]): The name of the bitstreams to replace. If not provided, the name is inferred from the source.
|
|
805
|
+
**upload_kwargs: Additional keyword arguments passed to the upload method.
|
|
806
|
+
|
|
807
|
+
Returns:
|
|
808
|
+
Dict[str, Any]: The metadata of the newly uploaded bitstream.
|
|
809
|
+
|
|
810
|
+
Raises:
|
|
811
|
+
DSpaceError: If the upload or replacement fails.
|
|
812
|
+
"""
|
|
813
|
+
with self._open_source(source, name) as src:
|
|
814
|
+
return self._replace_opened(bundle_uuid, src, **upload_kwargs)
|
|
815
|
+
|
|
816
|
+
def _replace_opened(self, bundle_uuid: str, src: _OpenedSource, **upload_kwargs) -> Dict[str, Any]:
|
|
817
|
+
"""Replace all bitstreams named `src.name` in the bundle with a new upload.
|
|
818
|
+
|
|
819
|
+
Parameters:
|
|
820
|
+
bundle_uuid (str): The UUID of the bundle containing the bitstreams to replace.
|
|
821
|
+
src (_OpenedSource): The opened source of the new file to upload.
|
|
822
|
+
**upload_kwargs: Additional keyword arguments passed to the upload method.
|
|
823
|
+
|
|
824
|
+
Returns:
|
|
825
|
+
Dict[str, Any]: The metadata of the newly uploaded bitstream.
|
|
826
|
+
|
|
827
|
+
Raises:
|
|
828
|
+
DSpaceError: If the upload or replacement fails.
|
|
829
|
+
"""
|
|
830
|
+
name = src.name
|
|
831
|
+
old = self.find_bitstreams_by_name(bundle_uuid, name)
|
|
832
|
+
primary = self.get_primary_bitstream(bundle_uuid) if old else None
|
|
833
|
+
old_uuids = [b["uuid"] for b in old]
|
|
834
|
+
was_primary = bool(primary and primary.get("uuid") in old_uuids)
|
|
835
|
+
|
|
836
|
+
new = self.upload_bitstream(bundle_uuid, src.file, name=name, **upload_kwargs)
|
|
837
|
+
|
|
838
|
+
if was_primary:
|
|
839
|
+
# Move the primary flag off the old bitstream before deleting it
|
|
840
|
+
self.set_primary_bitstream(bundle_uuid, new["uuid"])
|
|
841
|
+
if old_uuids:
|
|
842
|
+
self.delete_bitstreams(old_uuids)
|
|
843
|
+
logger.info("Replaced %d old '%s' bitstream(s) in bundle %s", len(old_uuids), name, bundle_uuid)
|
|
844
|
+
return new
|
|
845
|
+
|
|
846
|
+
# ------------------------------------------------------------------
|
|
847
|
+
# Single bitstreams
|
|
848
|
+
# ------------------------------------------------------------------
|
|
849
|
+
|
|
850
|
+
def get_bitstream(self, bitstream_uuid: str, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> Dict[str, Any]:
|
|
851
|
+
"""Return the bitstream with the given UUID.
|
|
852
|
+
|
|
853
|
+
Parameters:
|
|
854
|
+
bitstream_uuid (str): The UUID of the bitstream to retrieve.
|
|
855
|
+
all_languages (Optional[bool]): Whether to include all language variants of the metadata.
|
|
856
|
+
projection (ProjectionArg): The projection to apply to the response.
|
|
857
|
+
embed (EmbedArg): The embed options for the response.
|
|
858
|
+
|
|
859
|
+
Returns:
|
|
860
|
+
Dict[str, Any]: The metadata of the requested bitstream.
|
|
861
|
+
"""
|
|
862
|
+
url = self._url(Endpoints.BITSTREAMS, f"/{bitstream_uuid}")
|
|
863
|
+
return self._get_json(url, f"Get bitstream {bitstream_uuid}", all_languages=all_languages, projection=projection, embed=embed)
|
|
864
|
+
|
|
865
|
+
def patch_bitstream(self, bitstream_uuid: str, operations: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
866
|
+
"""Apply raw JSON Patch operations to a bitstream.
|
|
867
|
+
|
|
868
|
+
Parameters:
|
|
869
|
+
bitstream_uuid (str): The UUID of the bitstream to patch.
|
|
870
|
+
operations (List[Dict[str, Any]]): The list of JSON Patch operations to apply.
|
|
871
|
+
|
|
872
|
+
Returns:
|
|
873
|
+
Dict[str, Any]: The updated bitstream metadata.
|
|
874
|
+
|
|
875
|
+
Raises:
|
|
876
|
+
DSpaceError: If the patch operation fails.
|
|
877
|
+
"""
|
|
878
|
+
url = self._url(Endpoints.BITSTREAMS, f"/{bitstream_uuid}")
|
|
879
|
+
response = self._request(
|
|
880
|
+
"PATCH", url, f"Patch bitstream {bitstream_uuid}", expected=(200, 204),
|
|
881
|
+
headers=self._headers("application/json-patch+json"), json=operations,
|
|
882
|
+
)
|
|
883
|
+
return response.json() if response.status_code == 200 and response.content else self.get_bitstream(bitstream_uuid)
|
|
884
|
+
|
|
885
|
+
def update_bitstream_metadata(self, bitstream_uuid: str, operation: str,
|
|
886
|
+
metadata: Dict[str, Any]) -> Dict[str, Any]:
|
|
887
|
+
"""
|
|
888
|
+
Add / replace / remove bitstream metadata fields.
|
|
889
|
+
|
|
890
|
+
Parameters:
|
|
891
|
+
bitstream_uuid (str): The UUID of the bitstream to update.
|
|
892
|
+
operation (str): The operation to perform ("add", "replace", or "remove").
|
|
893
|
+
metadata (Dict[str, Any]): The metadata fields to update.
|
|
894
|
+
|
|
895
|
+
Returns:
|
|
896
|
+
Dict[str, Any]: The updated bitstream metadata.
|
|
897
|
+
|
|
898
|
+
Raises:
|
|
899
|
+
ValueError: If the operation is not one of "add", "replace", or "remove".
|
|
900
|
+
|
|
901
|
+
Examples:
|
|
902
|
+
update_bitstream_metadata(uuid, "replace", {"dc.description": "Full text"})
|
|
903
|
+
update_bitstream_metadata(uuid, "remove", {"dc.description": None})
|
|
904
|
+
"""
|
|
905
|
+
if operation not in ("add", "replace", "remove"):
|
|
906
|
+
raise ValueError("operation must be 'add', 'replace' or 'remove'")
|
|
907
|
+
ops = []
|
|
908
|
+
for field, values in metadata.items():
|
|
909
|
+
op = {"op": operation, "path": f"/metadata/{field}"}
|
|
910
|
+
if operation != "remove":
|
|
911
|
+
op["value"] = _format_metadata_field(values)
|
|
912
|
+
ops.append(op)
|
|
913
|
+
logger.info("%s metadata %s on bitstream %s", operation, list(metadata), bitstream_uuid)
|
|
914
|
+
return self.patch_bitstream(bitstream_uuid, ops)
|
|
915
|
+
|
|
916
|
+
def rename_bitstream(self, bitstream_uuid: str, new_name: str) -> Dict[str, Any]:
|
|
917
|
+
"""The bitstream name is its dc.title.
|
|
918
|
+
|
|
919
|
+
Parameters:
|
|
920
|
+
bitstream_uuid (str): The UUID of the bitstream to rename.
|
|
921
|
+
new_name (str): The new name for the bitstream.
|
|
922
|
+
|
|
923
|
+
Returns:
|
|
924
|
+
Dict[str, Any]: The updated bitstream metadata.
|
|
925
|
+
|
|
926
|
+
Raises:
|
|
927
|
+
ValueError: If the operation fails.
|
|
928
|
+
"""
|
|
929
|
+
return self.update_bitstream_metadata(bitstream_uuid, "replace", {"dc.title": new_name})
|
|
930
|
+
|
|
931
|
+
def delete_bitstream(self, bitstream_uuid: str) -> bool:
|
|
932
|
+
"""Delete the bitstream with the given UUID.
|
|
933
|
+
|
|
934
|
+
Parameters:
|
|
935
|
+
bitstream_uuid (str): The UUID of the bitstream to delete.
|
|
936
|
+
|
|
937
|
+
Returns:
|
|
938
|
+
bool: True if the deletion was successful.
|
|
939
|
+
|
|
940
|
+
Raises:
|
|
941
|
+
DSpaceError: If the deletion fails.
|
|
942
|
+
"""
|
|
943
|
+
logger.info("Deleting bitstream %s", bitstream_uuid)
|
|
944
|
+
url = self._url(Endpoints.BITSTREAMS, f"/{bitstream_uuid}")
|
|
945
|
+
self._request("DELETE", url, f"Delete bitstream {bitstream_uuid}", expected=(204,))
|
|
946
|
+
return True
|
|
947
|
+
|
|
948
|
+
def delete_bitstreams(self, bitstream_uuids: List[str]) -> bool:
|
|
949
|
+
"""Delete several bitstreams in one request (PATCH /core/bitstreams).
|
|
950
|
+
|
|
951
|
+
Parameters:
|
|
952
|
+
bitstream_uuids (List[str]): The list of UUIDs of the bitstreams to delete.
|
|
953
|
+
|
|
954
|
+
Returns:
|
|
955
|
+
bool: True if the deletion was successful.
|
|
956
|
+
|
|
957
|
+
Raises:
|
|
958
|
+
DSpaceError: If the deletion fails.
|
|
959
|
+
"""
|
|
960
|
+
if not bitstream_uuids:
|
|
961
|
+
return True
|
|
962
|
+
logger.info("Deleting %d bitstream(s)", len(bitstream_uuids))
|
|
963
|
+
ops = [{"op": "remove", "path": f"/bitstreams/{u}"} for u in bitstream_uuids]
|
|
964
|
+
self._request(
|
|
965
|
+
"PATCH", self._url(Endpoints.BITSTREAMS), "Delete bitstreams", expected=(204,),
|
|
966
|
+
headers=self._headers("application/json"), json=ops,
|
|
967
|
+
)
|
|
968
|
+
return True
|
|
969
|
+
|
|
970
|
+
def download_bitstream(self, bitstream_uuid: str, dest_path: Optional[str] = None,
|
|
971
|
+
chunk_size: int = 1024 * 1024) -> Union[bytes, str]:
|
|
972
|
+
"""Download bitstream content. Returns bytes, or the path if dest_path is given
|
|
973
|
+
(streamed to disk in chunks).
|
|
974
|
+
|
|
975
|
+
Parameters:
|
|
976
|
+
bitstream_uuid (str): The UUID of the bitstream to download.
|
|
977
|
+
dest_path (Optional[str]): The path to save the downloaded content. If None, the content is returned as bytes.
|
|
978
|
+
chunk_size (int): The size of chunks to read when streaming to disk.
|
|
979
|
+
|
|
980
|
+
Returns:
|
|
981
|
+
Union[bytes, str]: The downloaded content as bytes, or the path if dest_path is provided.
|
|
982
|
+
|
|
983
|
+
Raises:
|
|
984
|
+
DSpaceError: If the download fails.
|
|
985
|
+
"""
|
|
986
|
+
url = self._url(Endpoints.BITSTREAMS, f"/{bitstream_uuid}/content")
|
|
987
|
+
response = self._request(
|
|
988
|
+
"GET", url, f"Download bitstream {bitstream_uuid}",
|
|
989
|
+
headers=self._headers(content_type=None, accept="*/*"),
|
|
990
|
+
stream=dest_path is not None, timeout=self.UPLOAD_TIMEOUT, all_languages=False,
|
|
991
|
+
)
|
|
992
|
+
if dest_path is None:
|
|
993
|
+
return response.content
|
|
994
|
+
with open(dest_path, "wb") as fh:
|
|
995
|
+
for chunk in response.iter_content(chunk_size=chunk_size):
|
|
996
|
+
fh.write(chunk)
|
|
997
|
+
logger.info("Downloaded bitstream %s to %s", bitstream_uuid, dest_path)
|
|
998
|
+
return dest_path
|
|
999
|
+
|
|
1000
|
+
def get_bitstream_bundle(self, bitstream_uuid: str) -> Dict[str, Any]:
|
|
1001
|
+
"""Return the bundle containing the given bitstream.
|
|
1002
|
+
|
|
1003
|
+
Parameters:
|
|
1004
|
+
bitstream_uuid (str): The UUID of the bitstream.
|
|
1005
|
+
|
|
1006
|
+
Returns:
|
|
1007
|
+
Dict[str, Any]: The bundle containing the bitstream.
|
|
1008
|
+
|
|
1009
|
+
Raises:
|
|
1010
|
+
DSpaceError: If the request fails.
|
|
1011
|
+
"""
|
|
1012
|
+
url = self._url(Endpoints.BITSTREAMS, f"/{bitstream_uuid}/bundle")
|
|
1013
|
+
return self._request("GET", url, f"Get bundle of bitstream {bitstream_uuid}").json()
|
|
1014
|
+
|
|
1015
|
+
def move_bitstream_to_bundle(self, bitstream_uuid: str, target_bundle_uuid: str) -> Dict[str, Any]:
|
|
1016
|
+
"""Move a bitstream into another bundle (of the same item). Returns the updated bundle.
|
|
1017
|
+
|
|
1018
|
+
Parameters:
|
|
1019
|
+
bitstream_uuid (str): The UUID of the bitstream to move.
|
|
1020
|
+
target_bundle_uuid (str): The UUID of the target bundle.
|
|
1021
|
+
|
|
1022
|
+
Returns:
|
|
1023
|
+
Dict[str, Any]: The updated bundle containing the bitstream.
|
|
1024
|
+
|
|
1025
|
+
Raises:
|
|
1026
|
+
DSpaceError: If the move operation fails.
|
|
1027
|
+
"""
|
|
1028
|
+
logger.info("Moving bitstream %s to bundle %s", bitstream_uuid, target_bundle_uuid)
|
|
1029
|
+
url = self._url(Endpoints.BITSTREAMS, f"/{bitstream_uuid}/bundle")
|
|
1030
|
+
response = self._request(
|
|
1031
|
+
"PUT", url, f"Move bitstream {bitstream_uuid}", expected=(200, 204),
|
|
1032
|
+
headers=self._headers("text/uri-list"),
|
|
1033
|
+
data=self._uri_list(Endpoints.BUNDLES, f"/{target_bundle_uuid}"),
|
|
1034
|
+
)
|
|
1035
|
+
return response.json() if response.content else self.get_bitstream_bundle(bitstream_uuid)
|
|
1036
|
+
|
|
1037
|
+
# ------------------------------------------------------------------
|
|
1038
|
+
# Bitstream formats
|
|
1039
|
+
# ------------------------------------------------------------------
|
|
1040
|
+
|
|
1041
|
+
def get_bitstream_format(self, bitstream_uuid: str) -> Dict[str, Any]:
|
|
1042
|
+
"""The registry format currently assigned to a bitstream.
|
|
1043
|
+
|
|
1044
|
+
Parameters:
|
|
1045
|
+
bitstream_uuid (str): The UUID of the bitstream.
|
|
1046
|
+
|
|
1047
|
+
Returns:
|
|
1048
|
+
Dict[str, Any]: The registry format of the bitstream.
|
|
1049
|
+
|
|
1050
|
+
Raises:
|
|
1051
|
+
DSpaceError: If the request fails.
|
|
1052
|
+
"""
|
|
1053
|
+
url = self._url(Endpoints.BITSTREAMS, f"/{bitstream_uuid}/format")
|
|
1054
|
+
return self._request("GET", url, f"Get format of bitstream {bitstream_uuid}").json()
|
|
1055
|
+
|
|
1056
|
+
def set_bitstream_format(self, bitstream_uuid: str, format_id: int) -> Dict[str, Any]:
|
|
1057
|
+
"""Assign a registry format (by numeric id) to a bitstream.
|
|
1058
|
+
|
|
1059
|
+
Parameters:
|
|
1060
|
+
bitstream_uuid (str): The UUID of the bitstream.
|
|
1061
|
+
format_id (int): The numeric ID of the registry format to assign.
|
|
1062
|
+
|
|
1063
|
+
Returns:
|
|
1064
|
+
Dict[str, Any]: The updated bitstream metadata.
|
|
1065
|
+
|
|
1066
|
+
Raises:
|
|
1067
|
+
DSpaceError: If the operation fails.
|
|
1068
|
+
"""
|
|
1069
|
+
logger.info("Setting format of bitstream %s to %s", bitstream_uuid, format_id)
|
|
1070
|
+
url = self._url(Endpoints.BITSTREAMS, f"/{bitstream_uuid}/format")
|
|
1071
|
+
response = self._request(
|
|
1072
|
+
"PUT", url, f"Set format of bitstream {bitstream_uuid}", expected=(200, 204),
|
|
1073
|
+
headers=self._headers("text/uri-list"),
|
|
1074
|
+
data=self._uri_list(BITSTREAM_FORMATS, f"/{format_id}"),
|
|
1075
|
+
)
|
|
1076
|
+
return response.json() if response.content else self.get_bitstream(bitstream_uuid)
|
|
1077
|
+
|
|
1078
|
+
def set_bitstream_format_by_mimetype(self, bitstream_uuid: str, mime_type: str,
|
|
1079
|
+
only_if_different: bool = True) -> Optional[Dict[str, Any]]:
|
|
1080
|
+
"""Look up the registry format for mime_type and assign it. Returns None if nothing changed.
|
|
1081
|
+
Parameters:
|
|
1082
|
+
bitstream_uuid (str): The UUID of the bitstream.
|
|
1083
|
+
mime_type (str): The MIME type to look up in the registry.
|
|
1084
|
+
only_if_different (bool): If True, only set the format if it differs from the current one.
|
|
1085
|
+
|
|
1086
|
+
Returns:
|
|
1087
|
+
Optional[Dict[str, Any]]: The updated bitstream metadata, or None if nothing changed.
|
|
1088
|
+
|
|
1089
|
+
Raises:
|
|
1090
|
+
DSpaceError: If the operation fails.
|
|
1091
|
+
"""
|
|
1092
|
+
fmt = self.find_bitstream_format_by_mimetype(mime_type)
|
|
1093
|
+
if not fmt:
|
|
1094
|
+
logger.warning("No bitstream format with mimetype %s in the registry - format left unchanged", mime_type)
|
|
1095
|
+
return None
|
|
1096
|
+
if only_if_different:
|
|
1097
|
+
current = self.get_bitstream_format(bitstream_uuid)
|
|
1098
|
+
if current.get("id") == fmt.get("id"):
|
|
1099
|
+
return None
|
|
1100
|
+
return self.set_bitstream_format(bitstream_uuid, fmt["id"])
|
|
1101
|
+
|
|
1102
|
+
def get_bitstream_formats(self, refresh: bool = False) -> List[Dict[str, Any]]:
|
|
1103
|
+
"""Whole format registry (cached per manager instance)."""
|
|
1104
|
+
if self._format_cache is None or refresh:
|
|
1105
|
+
url = self._url(BITSTREAM_FORMATS)
|
|
1106
|
+
self._format_cache = list(self._iter_embedded(url, "bitstreamformats", "Get bitstream formats"))
|
|
1107
|
+
return self._format_cache
|
|
1108
|
+
|
|
1109
|
+
def get_bitstream_format_by_id(self, format_id: int) -> Dict[str, Any]:
|
|
1110
|
+
"""Return the bitstream format with the given ID."""
|
|
1111
|
+
url = self._url(BITSTREAM_FORMATS, f"/{format_id}")
|
|
1112
|
+
return self._request("GET", url, f"Get bitstream format {format_id}").json()
|
|
1113
|
+
|
|
1114
|
+
def find_bitstream_format_by_mimetype(self, mime_type: str) -> Optional[Dict[str, Any]]:
|
|
1115
|
+
"""Find a bitstream format by its MIME type. Returns None if not found.
|
|
1116
|
+
|
|
1117
|
+
Parameters:
|
|
1118
|
+
mime_type (str): The MIME type to look up in the registry.
|
|
1119
|
+
|
|
1120
|
+
Returns:
|
|
1121
|
+
Optional[Dict[str, Any]]: The bitstream format if found, otherwise None.
|
|
1122
|
+
"""
|
|
1123
|
+
mime_type = mime_type.lower()
|
|
1124
|
+
for fmt in self.get_bitstream_formats():
|
|
1125
|
+
if (fmt.get("mimetype") or "").lower() == mime_type:
|
|
1126
|
+
return fmt
|
|
1127
|
+
return None
|
|
1128
|
+
|
|
1129
|
+
def create_bitstream_format(self, short_description: str, mimetype: str, description: str = "",
|
|
1130
|
+
support_level: str = "KNOWN", internal: bool = False,
|
|
1131
|
+
extensions: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
1132
|
+
"""Register a new format (admin only). support_level: UNKNOWN, KNOWN or SUPPORTED.
|
|
1133
|
+
|
|
1134
|
+
Parameters:
|
|
1135
|
+
short_description (str): The short description of the format.
|
|
1136
|
+
mimetype (str): The MIME type of the format.
|
|
1137
|
+
description (str): The detailed description of the format.
|
|
1138
|
+
support_level (str): The support level of the format (UNKNOWN, KNOWN, SUPPORTED).
|
|
1139
|
+
internal (bool): Whether the format is internal.
|
|
1140
|
+
extensions (Optional[List[str]]): The list of file extensions associated with the format.
|
|
1141
|
+
|
|
1142
|
+
Returns:
|
|
1143
|
+
Dict[str, Any]: The newly created bitstream format.
|
|
1144
|
+
|
|
1145
|
+
Raises:
|
|
1146
|
+
DSpaceError: If the creation fails.
|
|
1147
|
+
"""
|
|
1148
|
+
body = {
|
|
1149
|
+
"shortDescription": short_description,
|
|
1150
|
+
"description": description,
|
|
1151
|
+
"mimetype": mimetype,
|
|
1152
|
+
"supportLevel": support_level,
|
|
1153
|
+
"internal": internal,
|
|
1154
|
+
"extensions": extensions or [],
|
|
1155
|
+
"type": "bitstreamformat",
|
|
1156
|
+
}
|
|
1157
|
+
fmt = self._request("POST", self._url(BITSTREAM_FORMATS), "Create bitstream format",
|
|
1158
|
+
expected=(200, 201), headers=self._headers(), json=body).json()
|
|
1159
|
+
self._format_cache = None
|
|
1160
|
+
return fmt
|
|
1161
|
+
|
|
1162
|
+
def update_bitstream_format(self, format_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
1163
|
+
"""
|
|
1164
|
+
Full update (PUT) of a registry format (admin only).
|
|
1165
|
+
|
|
1166
|
+
Parameters:
|
|
1167
|
+
format_id (int): The ID of the bitstream format to update.
|
|
1168
|
+
data (Dict[str, Any]): The data to update the format with.
|
|
1169
|
+
|
|
1170
|
+
Returns:
|
|
1171
|
+
Dict[str, Any]: The updated bitstream format.
|
|
1172
|
+
"""
|
|
1173
|
+
body = dict(data, id=format_id, type="bitstreamformat")
|
|
1174
|
+
fmt = self._request("PUT", self._url(BITSTREAM_FORMATS, f"/{format_id}"),
|
|
1175
|
+
f"Update bitstream format {format_id}", expected=(200, 201),
|
|
1176
|
+
headers=self._headers(), json=body).json()
|
|
1177
|
+
self._format_cache = None
|
|
1178
|
+
return fmt
|
|
1179
|
+
|
|
1180
|
+
def delete_bitstream_format(self, format_id: int) -> bool:
|
|
1181
|
+
"""
|
|
1182
|
+
Delete a bitstream format by its ID.
|
|
1183
|
+
|
|
1184
|
+
Parameters:
|
|
1185
|
+
format_id (int): The ID of the bitstream format to delete.
|
|
1186
|
+
|
|
1187
|
+
Returns:
|
|
1188
|
+
bool: True if the deletion was successful.
|
|
1189
|
+
"""
|
|
1190
|
+
self._request("DELETE", self._url(BITSTREAM_FORMATS, f"/{format_id}"),
|
|
1191
|
+
f"Delete bitstream format {format_id}", expected=(204,))
|
|
1192
|
+
self._format_cache = None
|
|
1193
|
+
return True
|