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/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