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/metadata.py
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Metadata for DSpace API (DSpace 7+ / DSpace-CRIS): JSON Patch builder,
|
|
4
|
+
generic metadata editing of any DSpace object, and the metadata registry.
|
|
5
|
+
|
|
6
|
+
MetadataPatch
|
|
7
|
+
One builder for every place metadata lives - only the base path differs:
|
|
8
|
+
|
|
9
|
+
archived objects /metadata (MetadataPatch())
|
|
10
|
+
submission sections /sections/<form> (MetadataPatch.for_section("traditionalpageone"))
|
|
11
|
+
uploaded file /sections/<upload>/files/<i>/metadata (MetadataPatch.for_file("upload", 0))
|
|
12
|
+
|
|
13
|
+
patch = (MetadataPatch()
|
|
14
|
+
.set("dc.subject", ["music", "opera"]) # initialise / replace all values
|
|
15
|
+
.append("dc.contributor.author", "Doe, Jane")
|
|
16
|
+
.insert("dc.title", 0, {"value": "Main", "language": "en"})
|
|
17
|
+
.replace_value("dc.title", 1, "Subtitle")
|
|
18
|
+
.set_property("dc.title", 0, "language", "de")
|
|
19
|
+
.remove("dc.description") # whole field
|
|
20
|
+
.remove("dc.subject", 1) # one value
|
|
21
|
+
.move("dc.contributor.author", 2, 0))
|
|
22
|
+
|
|
23
|
+
Registry
|
|
24
|
+
GET|POST /core/metadataschemas PUT|DELETE /core/metadataschemas/{id}
|
|
25
|
+
GET|POST /core/metadatafields?schemaId= PUT|DELETE /core/metadatafields/{id}
|
|
26
|
+
GET /core/metadatafields/search/bySchema?schema=
|
|
27
|
+
GET /core/metadatafields/search/byFieldName?exactName=|schema=|element=|qualifier=|query=
|
|
28
|
+
|
|
29
|
+
Fields named in a PATCH path must exist in the registry (else HTTP 422), so
|
|
30
|
+
imports can call ensure_fields() / unknown_fields() first.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
|
35
|
+
|
|
36
|
+
from ._base import BaseManager
|
|
37
|
+
from .projection import EmbedArg, ProjectionArg
|
|
38
|
+
|
|
39
|
+
# Logger for the module
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# REST endpoints for metadata schemas and fields
|
|
43
|
+
METADATA_SCHEMAS = "/core/metadataschemas"
|
|
44
|
+
METADATA_FIELDS = "/core/metadatafields"
|
|
45
|
+
|
|
46
|
+
# DSpace object type -> REST collection path (for patch_metadata / get_metadata)
|
|
47
|
+
DSO_PATHS = {
|
|
48
|
+
"item": "/core/items",
|
|
49
|
+
"collection": "/core/collections",
|
|
50
|
+
"community": "/core/communities",
|
|
51
|
+
"bundle": "/core/bundles",
|
|
52
|
+
"bitstream": "/core/bitstreams",
|
|
53
|
+
"eperson": "/eperson/epersons",
|
|
54
|
+
"group": "/eperson/groups",
|
|
55
|
+
"site": "/core/sites",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
ValueArg = Union[str, Dict[str, Any]]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def metadata_value(value: ValueArg, language: Optional[str] = None, authority: Optional[str] = None,
|
|
62
|
+
confidence: Optional[int] = None) -> Dict[str, Any]:
|
|
63
|
+
"""A MetadataValue dict from a string or dict (explicit arguments win over dict keys).
|
|
64
|
+
|
|
65
|
+
Parameters:
|
|
66
|
+
value (ValueArg): The metadata value, either as a string or a dict.
|
|
67
|
+
language (Optional[str]): The language of the metadata value.
|
|
68
|
+
authority (Optional[str]): The authority key for the metadata value.
|
|
69
|
+
confidence (Optional[int]): The confidence level for the metadata value.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Dict[str, Any]: The normalized metadata value dictionary.
|
|
73
|
+
"""
|
|
74
|
+
result = dict(value) if isinstance(value, dict) else {"value": "" if value is None else str(value)}
|
|
75
|
+
if "value" not in result:
|
|
76
|
+
raise ValueError(f"metadata value needs a 'value' key: {value!r}")
|
|
77
|
+
for key, extra in (("language", language), ("authority", authority), ("confidence", confidence)):
|
|
78
|
+
if extra is not None:
|
|
79
|
+
result[key] = extra
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_field(name: str) -> Tuple[str, str, Optional[str]]:
|
|
84
|
+
"""Parse a metadata field name into its components.
|
|
85
|
+
|
|
86
|
+
'dc.contributor.author' -> ('dc', 'contributor', 'author'); 'dc.title' -> ('dc', 'title', None)
|
|
87
|
+
|
|
88
|
+
Parameters:
|
|
89
|
+
name (str): The full metadata field name in the format 'schema.element[.qualifier]'.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Tuple[str, str, Optional[str]]: A tuple containing the schema, element, and optional qualifier.
|
|
93
|
+
"""
|
|
94
|
+
parts = (name or "").strip().split(".")
|
|
95
|
+
if len(parts) not in (2, 3) or not all(parts):
|
|
96
|
+
raise ValueError(f"Invalid metadata field name {name!r} (expected schema.element[.qualifier])")
|
|
97
|
+
return parts[0], parts[1], parts[2] if len(parts) == 3 else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def field_name(field: Dict[str, Any]) -> str:
|
|
101
|
+
"""Full name of a registry field dict (schema embedded or given as 'schema' prefix).
|
|
102
|
+
|
|
103
|
+
Parameters:
|
|
104
|
+
field (Dict[str, Any]): The registry field dictionary containing schema, element, and optional qualifier.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
str: The full metadata field name in the format 'schema.element[.qualifier]'.
|
|
108
|
+
"""
|
|
109
|
+
schema = field.get("schema")
|
|
110
|
+
prefix = schema.get("prefix") if isinstance(schema, dict) else schema
|
|
111
|
+
if not prefix:
|
|
112
|
+
prefix = ((field.get("_embedded") or {}).get("schema") or {}).get("prefix")
|
|
113
|
+
return ".".join(p for p in (prefix, field.get("element"), field.get("qualifier")) if p)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class MetadataPatch:
|
|
117
|
+
"""Builds JSON Patch operations for metadata (see module docstring)."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, base_path: str = "/metadata"):
|
|
120
|
+
"""
|
|
121
|
+
Initialize a MetadataPatch instance.
|
|
122
|
+
|
|
123
|
+
Parameters:
|
|
124
|
+
base_path (str): The base path for the metadata patch operations.
|
|
125
|
+
"""
|
|
126
|
+
self.base_path = base_path.rstrip("/")
|
|
127
|
+
self.operations: List[Dict[str, Any]] = []
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def for_section(cls, section: str) -> "MetadataPatch":
|
|
131
|
+
"""Metadata of a submission-form section of a workspace/workflow item.
|
|
132
|
+
|
|
133
|
+
Parameters:
|
|
134
|
+
section (str): The section identifier.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
MetadataPatch: A new MetadataPatch instance for the specified section.
|
|
138
|
+
"""
|
|
139
|
+
return cls(f"/sections/{section}")
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def for_file(cls, upload_section: str, file_index: int) -> "MetadataPatch":
|
|
143
|
+
"""Metadata of the file at `file_index` in an upload section.
|
|
144
|
+
|
|
145
|
+
Parameters:
|
|
146
|
+
upload_section (str): The upload section identifier.
|
|
147
|
+
file_index (int): The index of the file within the upload section.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
MetadataPatch: A new MetadataPatch instance for the specified file.
|
|
151
|
+
"""
|
|
152
|
+
return cls(f"/sections/{upload_section}/files/{int(file_index)}/metadata")
|
|
153
|
+
|
|
154
|
+
def _path(self, field: str, *rest) -> str:
|
|
155
|
+
"""
|
|
156
|
+
Construct the full path for a metadata field, optionally with additional path components.
|
|
157
|
+
|
|
158
|
+
Parameters:
|
|
159
|
+
field (str): The metadata field name.
|
|
160
|
+
*rest: Additional path components to append.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
str: The full path for the metadata field.
|
|
164
|
+
"""
|
|
165
|
+
parse_field(field)
|
|
166
|
+
return "/".join([self.base_path, field, *(str(r) for r in rest)])
|
|
167
|
+
|
|
168
|
+
def _op(self, op: str, path: str, **extra) -> "MetadataPatch":
|
|
169
|
+
"""
|
|
170
|
+
Add a metadata patch operation to the list of operations.
|
|
171
|
+
|
|
172
|
+
Parameters:
|
|
173
|
+
op (str): The operation type (e.g., 'add', 'replace', 'remove').
|
|
174
|
+
path (str): The path of the metadata field to operate on.
|
|
175
|
+
**extra: Additional keyword arguments for the operation.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
MetadataPatch: The current MetadataPatch instance.
|
|
179
|
+
"""
|
|
180
|
+
self.operations.append({"op": op, "path": path, **extra})
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
# -- whole field ----------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def archived(self) -> bool:
|
|
187
|
+
"""Check if the metadata patch is for archived objects.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
bool: True for archived objects (/metadata), False for submission sections.
|
|
191
|
+
"""
|
|
192
|
+
return self.base_path == "/metadata"
|
|
193
|
+
|
|
194
|
+
def _set_by_appending(self, field: str, items) -> "MetadataPatch":
|
|
195
|
+
"""
|
|
196
|
+
Set the metadata field by appending each item individually.
|
|
197
|
+
|
|
198
|
+
Parameters:
|
|
199
|
+
field (str): The metadata field name.
|
|
200
|
+
items (Sequence[Dict[str, Any]]): The list of metadata values to append.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
MetadataPatch: The current MetadataPatch instance.
|
|
204
|
+
"""
|
|
205
|
+
# DSpace applies only the first value of an array in add/replace on
|
|
206
|
+
# /metadata/<field>: clear the field, then append every value
|
|
207
|
+
self._op("remove", self._path(field))
|
|
208
|
+
for item in items:
|
|
209
|
+
self._op("add", self._path(field, "-"), value=item)
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
def set(self, field: str, values: Union[ValueArg, Sequence[ValueArg]], language: Optional[str] = None
|
|
213
|
+
) -> "MetadataPatch":
|
|
214
|
+
"""
|
|
215
|
+
Initialise the field or replace ALL its values. Archived objects:
|
|
216
|
+
'remove' + one 'add .../-' per value (works whether or not the field
|
|
217
|
+
has values). Submission sections: 'add' with the array.
|
|
218
|
+
|
|
219
|
+
Parameters:
|
|
220
|
+
field (str): The metadata field name.
|
|
221
|
+
values (Union[ValueArg, Sequence[ValueArg]]): The value(s) to set for the field.
|
|
222
|
+
language (Optional[str]): The language of the metadata values.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
MetadataPatch: The current MetadataPatch instance.
|
|
226
|
+
"""
|
|
227
|
+
items = [metadata_value(v, language) for v in (values if isinstance(values, (list, tuple)) else [values])]
|
|
228
|
+
if self.archived:
|
|
229
|
+
return self._set_by_appending(field, items)
|
|
230
|
+
return self._op("add", self._path(field), value=items)
|
|
231
|
+
|
|
232
|
+
def replace(self, field: str, values: Union[ValueArg, Sequence[ValueArg]], language: Optional[str] = None
|
|
233
|
+
) -> "MetadataPatch":
|
|
234
|
+
"""'replace' all values of an EXISTING field (several values on archived objects: like set()).
|
|
235
|
+
|
|
236
|
+
Parameters:
|
|
237
|
+
field (str): The metadata field name.
|
|
238
|
+
values (Union[ValueArg, Sequence[ValueArg]]): The value(s) to replace for the field.
|
|
239
|
+
language (Optional[str]): The language of the metadata values.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
MetadataPatch: The current MetadataPatch instance.
|
|
243
|
+
"""
|
|
244
|
+
items = [metadata_value(v, language) for v in (values if isinstance(values, (list, tuple)) else [values])]
|
|
245
|
+
if self.archived and len(items) > 1:
|
|
246
|
+
return self._set_by_appending(field, items)
|
|
247
|
+
return self._op("replace", self._path(field), value=items)
|
|
248
|
+
|
|
249
|
+
def remove(self, field: str, index: Optional[int] = None) -> "MetadataPatch":
|
|
250
|
+
"""
|
|
251
|
+
Remove the whole field, or only the value at `index`.
|
|
252
|
+
|
|
253
|
+
Parameters:
|
|
254
|
+
field (str): The metadata field name.
|
|
255
|
+
index (Optional[int]): The index of the value to remove. If None, remove the whole field.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
MetadataPatch: The current MetadataPatch instance.
|
|
259
|
+
"""
|
|
260
|
+
path = self._path(field) if index is None else self._path(field, int(index))
|
|
261
|
+
return self._op("remove", path)
|
|
262
|
+
|
|
263
|
+
# -- single values --------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def append(self, field: str, value: ValueArg, language: Optional[str] = None) -> "MetadataPatch":
|
|
266
|
+
"""Add one value at the end. The field must already have values - use set() for the first one.
|
|
267
|
+
|
|
268
|
+
Parameters:
|
|
269
|
+
field (str): The metadata field name.
|
|
270
|
+
value (ValueArg): The value to append.
|
|
271
|
+
language (Optional[str]): The language of the metadata value.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
MetadataPatch: The current MetadataPatch instance.
|
|
275
|
+
"""
|
|
276
|
+
return self._op("add", self._path(field, "-"), value=metadata_value(value, language))
|
|
277
|
+
|
|
278
|
+
def insert(self, field: str, index: int, value: ValueArg, language: Optional[str] = None) -> "MetadataPatch":
|
|
279
|
+
"""
|
|
280
|
+
Insert one value at position `index` (0-based); following values shift.
|
|
281
|
+
Note: on archived objects DSpace (8-10, CRIS) inserts at position 0
|
|
282
|
+
for any index other than '-' - use append() + move() for a position > 0.
|
|
283
|
+
|
|
284
|
+
Parameters:
|
|
285
|
+
field (str): The metadata field name.
|
|
286
|
+
index (int): The position to insert the value at (0-based).
|
|
287
|
+
value (ValueArg): The value to insert.
|
|
288
|
+
language (Optional[str]): The language of the metadata value.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
MetadataPatch: The current MetadataPatch instance.
|
|
292
|
+
"""
|
|
293
|
+
return self._op("add", self._path(field, int(index)), value=metadata_value(value, language))
|
|
294
|
+
|
|
295
|
+
def replace_value(self, field: str, index: int, value: ValueArg, language: Optional[str] = None
|
|
296
|
+
) -> "MetadataPatch":
|
|
297
|
+
"""
|
|
298
|
+
Replace one value at position `index` (0-based).
|
|
299
|
+
|
|
300
|
+
Parameters:
|
|
301
|
+
field (str): The metadata field name.
|
|
302
|
+
index (int): The position of the value to replace (0-based).
|
|
303
|
+
value (ValueArg): The new value to set.
|
|
304
|
+
language (Optional[str]): The language of the metadata value.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
MetadataPatch: The current MetadataPatch instance.
|
|
308
|
+
"""
|
|
309
|
+
return self._op("replace", self._path(field, int(index)), value=metadata_value(value, language))
|
|
310
|
+
|
|
311
|
+
def set_property(self, field: str, index: int, prop: str, value: Any) -> "MetadataPatch":
|
|
312
|
+
"""
|
|
313
|
+
Replace one property (value, language, authority, confidence) of one value.
|
|
314
|
+
|
|
315
|
+
Parameters:
|
|
316
|
+
field (str): The metadata field name.
|
|
317
|
+
index (int): The index of the value to modify.
|
|
318
|
+
prop (str): The property to replace ('value', 'language', 'authority', 'confidence').
|
|
319
|
+
value (Any): The new value for the property.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
MetadataPatch: The current MetadataPatch instance.
|
|
323
|
+
"""
|
|
324
|
+
if prop not in ("value", "language", "authority", "confidence"):
|
|
325
|
+
raise ValueError(f"Unknown metadata value property {prop!r}")
|
|
326
|
+
return self._op("replace", self._path(field, int(index), prop), value=value)
|
|
327
|
+
|
|
328
|
+
def move(self, field: str, from_index: int, to_index: int) -> "MetadataPatch":
|
|
329
|
+
"""Re-order values of a repeatable field.
|
|
330
|
+
|
|
331
|
+
Parameters:
|
|
332
|
+
field (str): The metadata field name.
|
|
333
|
+
from_index (int): The current position of the value to move (0-based).
|
|
334
|
+
to_index (int): The new position of the value (0-based).
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
MetadataPatch: The current MetadataPatch instance.
|
|
338
|
+
"""
|
|
339
|
+
return self._op("move", self._path(field, int(to_index)), **{"from": self._path(field, int(from_index))})
|
|
340
|
+
|
|
341
|
+
# -- helpers ----------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def extend(self, other: "MetadataPatch") -> "MetadataPatch":
|
|
344
|
+
"""Extend the current patch with another patch.
|
|
345
|
+
|
|
346
|
+
Parameters:
|
|
347
|
+
other (MetadataPatch): Another MetadataPatch instance to merge into this one.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
MetadataPatch: The current MetadataPatch instance.
|
|
351
|
+
"""
|
|
352
|
+
self.operations.extend(other.operations)
|
|
353
|
+
return self
|
|
354
|
+
|
|
355
|
+
def fields(self) -> List[str]:
|
|
356
|
+
"""Metadata fields touched by this patch (for registry checks).
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
List[str]: A list of metadata field names affected by this patch.
|
|
360
|
+
"""
|
|
361
|
+
names = []
|
|
362
|
+
for op in self.operations:
|
|
363
|
+
for path in (op["path"], op.get("from")):
|
|
364
|
+
if path:
|
|
365
|
+
name = path[len(self.base_path) + 1:].split("/", 1)[0]
|
|
366
|
+
if name not in names:
|
|
367
|
+
names.append(name)
|
|
368
|
+
return names
|
|
369
|
+
|
|
370
|
+
def __len__(self):
|
|
371
|
+
"""Number of operations in this patch."""
|
|
372
|
+
return len(self.operations)
|
|
373
|
+
|
|
374
|
+
def __iter__(self):
|
|
375
|
+
"""Iterate over the operations in this patch."""
|
|
376
|
+
return iter(self.operations)
|
|
377
|
+
|
|
378
|
+
def __repr__(self):
|
|
379
|
+
"""String representation of the MetadataPatch."""
|
|
380
|
+
return f"MetadataPatch({self.base_path!r}, {len(self)} op(s))"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def as_operations(patch: Union[MetadataPatch, Iterable[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
|
384
|
+
"""Convert a MetadataPatch or an iterable of raw operations into a list of operations."""
|
|
385
|
+
return list(patch.operations if isinstance(patch, MetadataPatch) else patch)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class MetadataManager(BaseManager):
|
|
389
|
+
"""Metadata registry and metadata PATCH for any DSpace object"""
|
|
390
|
+
|
|
391
|
+
def __init__(self, auth):
|
|
392
|
+
"""Initialize the MetadataManager with authentication."""
|
|
393
|
+
super().__init__(auth)
|
|
394
|
+
self._field_cache: Dict[str, Optional[Dict[str, Any]]] = {}
|
|
395
|
+
self._schema_cache: Dict[str, Dict[str, Any]] = {}
|
|
396
|
+
|
|
397
|
+
# ------------------------------------------------------------------
|
|
398
|
+
# Metadata of any DSpace object
|
|
399
|
+
# ------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
def _dso_url(self, dso_type: str, uuid: str) -> str:
|
|
402
|
+
"""Construct the URL for a DSpace object based on its type and UUID.
|
|
403
|
+
|
|
404
|
+
Parameters:
|
|
405
|
+
dso_type (str): The type of the DSpace object (e.g., 'item', 'collection').
|
|
406
|
+
uuid (str): The UUID of the DSpace object.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
str: The URL for the specified DSpace object.
|
|
410
|
+
|
|
411
|
+
Raises:
|
|
412
|
+
ValueError: If the dso_type is not recognized.
|
|
413
|
+
"""
|
|
414
|
+
if dso_type not in DSO_PATHS:
|
|
415
|
+
raise ValueError(f"dso_type must be one of {sorted(DSO_PATHS)}, not {dso_type!r}")
|
|
416
|
+
return self._url(DSO_PATHS[dso_type], f"/{uuid}")
|
|
417
|
+
|
|
418
|
+
def get_metadata(self, dso_type: str, uuid: str, all_languages: Optional[bool] = True
|
|
419
|
+
) -> Dict[str, List[Dict[str, Any]]]:
|
|
420
|
+
"""Retrieve the metadata map of a DSpace object.
|
|
421
|
+
|
|
422
|
+
By default, retrieves all languages so that indexes match PATCH positions.
|
|
423
|
+
|
|
424
|
+
Parameters:
|
|
425
|
+
dso_type (str): The type of the DSpace object (e.g., 'item', 'collection').
|
|
426
|
+
uuid (str): The UUID of the DSpace object.
|
|
427
|
+
all_languages (Optional[bool]): Whether to retrieve metadata in all languages. Defaults to True.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
Dict[str, List[Dict[str, Any]]]: The metadata map of the object.
|
|
431
|
+
"""
|
|
432
|
+
return self._get_json(self._dso_url(dso_type, uuid), f"Get {dso_type} {uuid}",
|
|
433
|
+
all_languages=all_languages).get("metadata") or {}
|
|
434
|
+
|
|
435
|
+
def patch_metadata(self, dso_type: str, uuid: str, patch: Union[MetadataPatch, Iterable[Dict[str, Any]]],
|
|
436
|
+
check_fields: bool = False) -> Dict[str, Any]:
|
|
437
|
+
"""
|
|
438
|
+
Apply a MetadataPatch (or raw operations) to an item, collection,
|
|
439
|
+
community, bundle, bitstream, eperson, group or the site.
|
|
440
|
+
check_fields=True verifies the fields exist in the registry first and
|
|
441
|
+
raises ValueError listing the unknown ones (instead of a bare 422).
|
|
442
|
+
|
|
443
|
+
Parameters:
|
|
444
|
+
dso_type (str): The type of the DSpace object (e.g., 'item', 'collection').
|
|
445
|
+
uuid (str): The UUID of the DSpace object.
|
|
446
|
+
patch (Union[MetadataPatch, Iterable[Dict[str, Any]]]): The metadata patch to apply.
|
|
447
|
+
check_fields (bool): Whether to check that the fields exist in the registry before applying. Defaults to False.
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
Dict[str, Any]: The response from the DSpace API after applying the patch.
|
|
451
|
+
"""
|
|
452
|
+
operations = as_operations(patch)
|
|
453
|
+
if not operations:
|
|
454
|
+
raise ValueError("Empty metadata patch")
|
|
455
|
+
if check_fields and isinstance(patch, MetadataPatch):
|
|
456
|
+
unknown = self.unknown_fields(patch.fields())
|
|
457
|
+
if unknown:
|
|
458
|
+
raise ValueError(f"Unknown metadata field(s), not in the registry: {unknown}")
|
|
459
|
+
logger.info("Patching metadata of %s %s (%d op(s))", dso_type, uuid, len(operations))
|
|
460
|
+
return self._request("PATCH", self._dso_url(dso_type, uuid), f"Patch metadata of {dso_type} {uuid}",
|
|
461
|
+
headers=self._headers(), json=operations).json()
|
|
462
|
+
|
|
463
|
+
# ------------------------------------------------------------------
|
|
464
|
+
# Schemas
|
|
465
|
+
# ------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
def get_schemas(self) -> List[Dict[str, Any]]:
|
|
468
|
+
"""List all metadata schemas.
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
List[Dict[str, Any]]: A list of metadata schemas.
|
|
472
|
+
"""
|
|
473
|
+
return list(self._iter_embedded(self._url(METADATA_SCHEMAS), "metadataschemas", "List metadata schemas"))
|
|
474
|
+
|
|
475
|
+
def get_schema(self, schema_id: int) -> Dict[str, Any]:
|
|
476
|
+
"""Retrieve a metadata schema by its ID.
|
|
477
|
+
|
|
478
|
+
Parameters:
|
|
479
|
+
schema_id (int): The ID of the metadata schema.
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
Dict[str, Any]: The metadata schema.
|
|
483
|
+
"""
|
|
484
|
+
return self._get_json(self._url(METADATA_SCHEMAS, f"/{schema_id}"), f"Get metadata schema {schema_id}")
|
|
485
|
+
|
|
486
|
+
def find_schema(self, prefix: str, refresh: bool = False) -> Optional[Dict[str, Any]]:
|
|
487
|
+
"""
|
|
488
|
+
Retrieve a metadata schema by its prefix.
|
|
489
|
+
|
|
490
|
+
Parameters:
|
|
491
|
+
prefix (str): The prefix of the metadata schema (e.g., 'dc', 'dcterms', 'crisrp').
|
|
492
|
+
refresh (bool): Whether to refresh the schema cache. Defaults to False.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
Optional[Dict[str, Any]]: The metadata schema if found, or None.
|
|
496
|
+
"""
|
|
497
|
+
if refresh or prefix not in self._schema_cache:
|
|
498
|
+
self._schema_cache = {s.get("prefix"): s for s in self.get_schemas()}
|
|
499
|
+
return self._schema_cache.get(prefix)
|
|
500
|
+
|
|
501
|
+
def create_schema(self, prefix: str, namespace: str) -> Dict[str, Any]:
|
|
502
|
+
"""Create a new metadata schema.
|
|
503
|
+
|
|
504
|
+
Admin only. 422 for an empty prefix, dots/commas/spaces, > 32 chars, or empty namespace.
|
|
505
|
+
|
|
506
|
+
Parameters:
|
|
507
|
+
prefix (str): The prefix of the metadata schema (e.g., 'dc', 'dcterms', 'crisrp').
|
|
508
|
+
namespace (str): The namespace URI of the metadata schema.
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
Dict[str, Any]: The created metadata schema.
|
|
512
|
+
"""
|
|
513
|
+
schema = self._request("POST", self._url(METADATA_SCHEMAS), f"Create metadata schema {prefix}",
|
|
514
|
+
expected=(200, 201), headers=self._headers(),
|
|
515
|
+
json={"prefix": prefix, "namespace": namespace}).json()
|
|
516
|
+
self._schema_cache[prefix] = schema
|
|
517
|
+
logger.info("Created metadata schema %s (%s)", prefix, schema.get("id"))
|
|
518
|
+
return schema
|
|
519
|
+
|
|
520
|
+
def update_schema(self, schema_id: int, namespace: str) -> Dict[str, Any]:
|
|
521
|
+
"""Change the namespace (the prefix cannot be changed).
|
|
522
|
+
|
|
523
|
+
Parameters:
|
|
524
|
+
schema_id (int): The ID of the metadata schema.
|
|
525
|
+
namespace (str): The new namespace URI for the metadata schema.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
Dict[str, Any]: The updated metadata schema.
|
|
529
|
+
"""
|
|
530
|
+
current = self.get_schema(schema_id)
|
|
531
|
+
body = {"id": schema_id, "prefix": current["prefix"], "namespace": namespace}
|
|
532
|
+
schema = self._request("PUT", self._url(METADATA_SCHEMAS, f"/{schema_id}"),
|
|
533
|
+
f"Update metadata schema {schema_id}", headers=self._headers(), json=body).json()
|
|
534
|
+
self._schema_cache.pop(current["prefix"], None)
|
|
535
|
+
return schema
|
|
536
|
+
|
|
537
|
+
def delete_schema(self, schema_id: int) -> bool:
|
|
538
|
+
"""Delete a metadata schema.
|
|
539
|
+
|
|
540
|
+
Admin only. 422 if the schema is in use.
|
|
541
|
+
|
|
542
|
+
Parameters:
|
|
543
|
+
schema_id (int): The ID of the metadata schema to delete.
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
bool: True if the schema was successfully deleted.
|
|
547
|
+
"""
|
|
548
|
+
self._request("DELETE", self._url(METADATA_SCHEMAS, f"/{schema_id}"), f"Delete metadata schema {schema_id}",
|
|
549
|
+
expected=(204,))
|
|
550
|
+
self._schema_cache.clear()
|
|
551
|
+
self._field_cache.clear()
|
|
552
|
+
return True
|
|
553
|
+
|
|
554
|
+
# ------------------------------------------------------------------
|
|
555
|
+
# Fields
|
|
556
|
+
# ------------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
def get_field(self, field_id: int, embed: EmbedArg = "schema") -> Dict[str, Any]:
|
|
559
|
+
"""Retrieve a metadata field by its ID.
|
|
560
|
+
|
|
561
|
+
Parameters:
|
|
562
|
+
field_id (int): The ID of the metadata field.
|
|
563
|
+
embed (EmbedArg): The embed parameter for the request. Defaults to "schema".
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
Dict[str, Any]: The metadata field.
|
|
567
|
+
"""
|
|
568
|
+
return self._get_json(self._url(METADATA_FIELDS, f"/{field_id}"), f"Get metadata field {field_id}",
|
|
569
|
+
embed=embed)
|
|
570
|
+
|
|
571
|
+
def get_fields_by_schema(self, prefix: str) -> List[Dict[str, Any]]:
|
|
572
|
+
"""Retrieve all metadata fields for a given schema prefix.
|
|
573
|
+
|
|
574
|
+
Parameters:
|
|
575
|
+
prefix (str): The schema prefix (e.g., 'dc', 'dcterms', 'crisrp').
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
List[Dict[str, Any]]: A list of metadata fields belonging to the specified schema.
|
|
579
|
+
"""
|
|
580
|
+
url = self._url(METADATA_FIELDS, "/search/bySchema")
|
|
581
|
+
return list(self._iter_embedded(url, "metadatafields", f"List fields of schema {prefix}",
|
|
582
|
+
[("schema", prefix)]))
|
|
583
|
+
|
|
584
|
+
def search_fields(self, query: Optional[str] = None, schema: Optional[str] = None,
|
|
585
|
+
element: Optional[str] = None, qualifier: Optional[str] = None,
|
|
586
|
+
exact_name: Optional[str] = None, max_results: Optional[int] = None,
|
|
587
|
+
projection: ProjectionArg = None) -> List[Dict[str, Any]]:
|
|
588
|
+
"""Search metadata fields by various criteria.
|
|
589
|
+
|
|
590
|
+
Every argument is optional and they combine.
|
|
591
|
+
|
|
592
|
+
Parameters:
|
|
593
|
+
query (Optional[str]): The search query.
|
|
594
|
+
schema (Optional[str]): The schema prefix to filter by.
|
|
595
|
+
element (Optional[str]): The element name to filter by.
|
|
596
|
+
qualifier (Optional[str]): The qualifier to filter by.
|
|
597
|
+
exact_name (Optional[str]): The exact field name to match.
|
|
598
|
+
max_results (Optional[int]): The maximum number of results to return.
|
|
599
|
+
projection (ProjectionArg): The projection parameter for the request.
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
List[Dict[str, Any]]: A list of metadata fields matching the criteria.
|
|
603
|
+
"""
|
|
604
|
+
params = [(k, v) for k, v in (("query", query), ("schema", schema), ("element", element),
|
|
605
|
+
("qualifier", qualifier), ("exactName", exact_name)) if v]
|
|
606
|
+
url = self._url(METADATA_FIELDS, "/search/byFieldName")
|
|
607
|
+
return list(self._iter_embedded(url, "metadatafields", "Search metadata fields", params,
|
|
608
|
+
max_results=max_results, projection=projection, embed="schema"))
|
|
609
|
+
|
|
610
|
+
def find_field(self, name: str, refresh: bool = False) -> Optional[Dict[str, Any]]:
|
|
611
|
+
"""The registry field for 'schema.element[.qualifier]', or None (cached).
|
|
612
|
+
|
|
613
|
+
Parameters:
|
|
614
|
+
name (str): The full name of the metadata field in the format 'schema.element[.qualifier]'.
|
|
615
|
+
refresh (bool): If True, refresh the cache and fetch the field from the server.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
Optional[Dict[str, Any]]: The metadata field if found, otherwise None.
|
|
619
|
+
"""
|
|
620
|
+
parse_field(name)
|
|
621
|
+
if refresh or name not in self._field_cache:
|
|
622
|
+
matches = [f for f in self.search_fields(exact_name=name) if self._same_field(f, name)]
|
|
623
|
+
self._field_cache[name] = matches[0] if matches else None
|
|
624
|
+
return self._field_cache[name]
|
|
625
|
+
|
|
626
|
+
@staticmethod
|
|
627
|
+
def _same_field(field: Dict[str, Any], name: str) -> bool:
|
|
628
|
+
"""Check if a metadata field matches the given full name.
|
|
629
|
+
|
|
630
|
+
exactName should return only the match; verify anyway (schema prefix only if the server sent it).
|
|
631
|
+
|
|
632
|
+
Parameters:
|
|
633
|
+
field (Dict[str, Any]): The metadata field to check.
|
|
634
|
+
name (str): The full name of the metadata field in the format 'schema.element[.qualifier]'.
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
bool: True if the field matches the given name, False otherwise.
|
|
638
|
+
"""
|
|
639
|
+
_prefix, element, qualifier = parse_field(name)
|
|
640
|
+
if field.get("schema") or (field.get("_embedded") or {}).get("schema"):
|
|
641
|
+
return field_name(field) == name
|
|
642
|
+
return field.get("element") == element and (field.get("qualifier") or None) == qualifier
|
|
643
|
+
|
|
644
|
+
def field_exists(self, name: str) -> bool:
|
|
645
|
+
"""Check if a metadata field exists in the registry.
|
|
646
|
+
|
|
647
|
+
Parameters:
|
|
648
|
+
name (str): The full name of the metadata field in the format 'schema.element[.qualifier]'.
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
bool: True if the field exists, False otherwise.
|
|
652
|
+
"""
|
|
653
|
+
return self.find_field(name) is not None
|
|
654
|
+
|
|
655
|
+
def unknown_fields(self, names: Iterable[str]) -> List[str]:
|
|
656
|
+
"""The names (in order, no duplicates) that are not in the registry.
|
|
657
|
+
|
|
658
|
+
Parameters:
|
|
659
|
+
names (Iterable[str]): A list of metadata field names to check.
|
|
660
|
+
|
|
661
|
+
Returns:
|
|
662
|
+
List[str]: A list of metadata field names that are not found in the registry.
|
|
663
|
+
"""
|
|
664
|
+
seen, unknown = set(), []
|
|
665
|
+
for name in names:
|
|
666
|
+
if name not in seen:
|
|
667
|
+
seen.add(name)
|
|
668
|
+
if not self.field_exists(name):
|
|
669
|
+
unknown.append(name)
|
|
670
|
+
return unknown
|
|
671
|
+
|
|
672
|
+
def create_field(self, name: str, scope_note: Optional[str] = None) -> Dict[str, Any]:
|
|
673
|
+
"""Register 'schema.element[.qualifier]' (admin only; the schema must exist).
|
|
674
|
+
|
|
675
|
+
Parameters:
|
|
676
|
+
name (str): The full name of the metadata field in the format 'schema.element[.qualifier]'.
|
|
677
|
+
scope_note (Optional[str]): The scope note for the metadata field.
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
Dict[str, Any]: The created metadata field.
|
|
681
|
+
"""
|
|
682
|
+
prefix, element, qualifier = parse_field(name)
|
|
683
|
+
schema = self.find_schema(prefix)
|
|
684
|
+
if not schema:
|
|
685
|
+
raise ValueError(f"Metadata schema {prefix!r} does not exist - create_schema() first")
|
|
686
|
+
body = {"element": element, "qualifier": qualifier, "scopeNote": scope_note}
|
|
687
|
+
field = self._request("POST", self._url(METADATA_FIELDS), f"Create metadata field {name}",
|
|
688
|
+
expected=(200, 201), headers=self._headers(), params={"schemaId": schema["id"]},
|
|
689
|
+
json=body).json()
|
|
690
|
+
self._field_cache[name] = field
|
|
691
|
+
logger.info("Created metadata field %s (%s)", name, field.get("id"))
|
|
692
|
+
return field
|
|
693
|
+
|
|
694
|
+
def ensure_fields(self, names: Iterable[str], scope_note: Optional[str] = None, dry_run: bool = False
|
|
695
|
+
) -> List[str]:
|
|
696
|
+
"""Create the fields that are missing. Returns the names created (or, with dry_run, to create).
|
|
697
|
+
|
|
698
|
+
Parameters:
|
|
699
|
+
names (Iterable[str]): A list of metadata field names to ensure.
|
|
700
|
+
scope_note (Optional[str]): The scope note for the metadata fields.
|
|
701
|
+
dry_run (bool): If True, do not actually create the fields, just return the names that would be created.
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
List[str]: The names of the metadata fields that were created (or would be created with dry_run).
|
|
705
|
+
"""
|
|
706
|
+
missing = self.unknown_fields(names)
|
|
707
|
+
if not dry_run:
|
|
708
|
+
for name in missing:
|
|
709
|
+
self.create_field(name, scope_note)
|
|
710
|
+
return missing
|
|
711
|
+
|
|
712
|
+
def update_field(self, field_id: int, scope_note: Optional[str]) -> Dict[str, Any]:
|
|
713
|
+
"""Change the scope note (element and qualifier cannot be changed).
|
|
714
|
+
|
|
715
|
+
Parameters:
|
|
716
|
+
field_id (int): The ID of the metadata field to update.
|
|
717
|
+
scope_note (Optional[str]): The new scope note for the metadata field.
|
|
718
|
+
|
|
719
|
+
Returns:
|
|
720
|
+
Dict[str, Any]: The updated metadata field.
|
|
721
|
+
"""
|
|
722
|
+
current = self.get_field(field_id, embed=None)
|
|
723
|
+
body = {"id": field_id, "element": current["element"], "qualifier": current.get("qualifier"),
|
|
724
|
+
"scopeNote": scope_note}
|
|
725
|
+
field = self._request("PUT", self._url(METADATA_FIELDS, f"/{field_id}"), f"Update metadata field {field_id}",
|
|
726
|
+
headers=self._headers(), json=body).json()
|
|
727
|
+
self._field_cache.clear()
|
|
728
|
+
return field
|
|
729
|
+
|
|
730
|
+
def delete_field(self, field_id: int) -> bool:
|
|
731
|
+
"""Delete a metadata field by its ID.
|
|
732
|
+
|
|
733
|
+
Parameters:
|
|
734
|
+
field_id (int): The ID of the metadata field to delete.
|
|
735
|
+
|
|
736
|
+
Returns:
|
|
737
|
+
bool: True if the field was successfully deleted.
|
|
738
|
+
"""
|
|
739
|
+
self._request("DELETE", self._url(METADATA_FIELDS, f"/{field_id}"), f"Delete metadata field {field_id}",
|
|
740
|
+
expected=(204,))
|
|
741
|
+
self._field_cache.clear()
|
|
742
|
+
return True
|