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.
@@ -0,0 +1,292 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ Projections and embeds for DSpace REST requests.
4
+
5
+ DSpace lets every HAL GET request shape its response (RestContract
6
+ projections.md):
7
+
8
+ projection=full every link embedded, max. two levels
9
+ projection=allLanguages metadata in every language (DSpace-CRIS)
10
+ embed=bundles embed one linked resource
11
+ embed=bundles/bitstreams nested: the bundles AND their bitstreams
12
+ embed=bundles&embed=owningCollection several (or comma separated)
13
+ embed.size=bundles=50 page size of an embedded list
14
+
15
+ Several projections can be combined by repeating the parameter; DSpace
16
+ builds a composite projection (Utils.obtainProjection). embed.size is not
17
+ described on the contract page but is supported by the server; without it
18
+ an embedded list holds only its default page (usually 20) - use
19
+ get_embedded_page() / is_embedded_complete() to detect truncation.
20
+
21
+ The toolkit accepts these per call on its read methods:
22
+
23
+ client.items.get_item(uuid, embed=["bundles/bitstreams", "owningCollection"])
24
+ client.items.get_item(uuid, embed={"bundles": 50, "bundles/bitstreams": 200})
25
+ client.collections.get_collection(uuid, projection="full")
26
+
27
+ and the helpers below read the result:
28
+
29
+ get_embedded(item, "owningCollection") -> dict or None
30
+ get_embedded(item, "bundles/bitstreams") -> flat list of bitstreams
31
+ """
32
+
33
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
34
+
35
+ # Standard projection and embed constants and normalization functions for DSpace REST API requests.
36
+ FULL = "full"
37
+ ALL_LANGUAGES = "allLanguages"
38
+
39
+ # Accepted argument shapes
40
+ EmbedArg = Union[None, str, Iterable[str], Mapping[str, Optional[int]]]
41
+ ProjectionArg = Union[None, str, Iterable[str]]
42
+
43
+
44
+ def _split(value) -> List[str]:
45
+ """Split a comma-separated string or iterable into a list of non-empty, stripped strings."""
46
+ if value is None:
47
+ return []
48
+ if isinstance(value, str):
49
+ value = value.split(",")
50
+ return [v.strip() for v in value if v and str(v).strip()]
51
+
52
+
53
+ def normalize_projection(projection: ProjectionArg) -> List[str]:
54
+ """Normalize a projection argument into a list of unique projection names.
55
+
56
+ 'full' / 'full,allLanguages' / ['full'] -> ['full', ...] (order kept, no duplicates).
57
+ """
58
+ seen: List[str] = []
59
+ for name in _split(projection):
60
+ if name not in seen:
61
+ seen.append(name)
62
+ return seen
63
+
64
+
65
+ def _clean_path(path: str) -> str:
66
+ """Clean and validate an embed path, ensuring it is non-empty and properly formatted."""
67
+ path = str(path).strip().strip("/")
68
+ if not path or any(not part.strip() for part in path.split("/")):
69
+ raise ValueError(f"Invalid embed path {path!r}")
70
+ return "/".join(part.strip() for part in path.split("/"))
71
+
72
+
73
+ def normalize_embed(embed: EmbedArg) -> Tuple[List[str], Dict[str, int]]:
74
+ """Normalize an embed argument into a list of unique embed paths and a dictionary of embed sizes.
75
+
76
+ -> (embed paths, embed sizes)
77
+
78
+ embed may be a string ("bundles,owningCollection"), a list of paths, or a
79
+ dict {path: size or None}. A size applies to that embedded list.
80
+
81
+ Parameters:
82
+ embed (EmbedArg): The embed argument to normalize.
83
+
84
+ Returns:
85
+ Tuple[List[str], Dict[str, int]]: A tuple containing the list of unique embed paths and a dictionary of embed sizes.
86
+ """
87
+ if embed is None:
88
+ return [], {}
89
+ sizes: Dict[str, int] = {}
90
+ if isinstance(embed, Mapping):
91
+ paths = []
92
+ for path, size in embed.items():
93
+ clean = _clean_path(path)
94
+ paths.append(clean)
95
+ if size is not None:
96
+ if isinstance(size, bool) or not isinstance(size, int) or size < 1:
97
+ raise ValueError(f"embed size for {path!r} must be a positive int, not {size!r}")
98
+ sizes[clean] = size
99
+ else:
100
+ paths = [_clean_path(p) for p in _split(embed)]
101
+ unique: List[str] = []
102
+ for path in paths:
103
+ if path not in unique:
104
+ unique.append(path)
105
+ return unique, sizes
106
+
107
+
108
+ def _merge(params, extra: Sequence[Tuple[str, str]]):
109
+ """Add (key, value) pairs to params (None / dict / list of tuples), skipping exact duplicates.
110
+
111
+ Parameters:
112
+ params (None | dict | list of tuples): The existing parameters to merge into.
113
+ extra (Sequence[Tuple[str, str]]): The additional (key, value) pairs to add.
114
+
115
+ Returns:
116
+ The merged parameters in the same shape as the input `params`.
117
+ """
118
+ if not extra:
119
+ return params
120
+ if params is None:
121
+ params = []
122
+ if isinstance(params, Mapping):
123
+ merged = dict(params)
124
+ for key, value in extra:
125
+ current = merged.get(key)
126
+ if current is None:
127
+ merged[key] = value
128
+ continue
129
+ values = list(current) if isinstance(current, (list, tuple)) else [current]
130
+ if value not in values:
131
+ values.append(value)
132
+ merged[key] = values if len(values) > 1 else values[0]
133
+ return merged
134
+ items = list(params)
135
+ for pair in extra:
136
+ if pair not in items:
137
+ items.append(pair)
138
+ return items
139
+
140
+
141
+ def apply_view(params, projection: ProjectionArg = None, embed: EmbedArg = None):
142
+ """
143
+ Return params with projection / embed / embed.size added, in the same
144
+ shape that was passed (None becomes a list of tuples). Existing values
145
+ (e.g. projection=preventMetadataSecurity, embed=group) are kept.
146
+
147
+ Parameters:
148
+ params (None | dict | list of tuples): The existing parameters to merge into.
149
+ projection (ProjectionArg, optional): The projection argument to apply.
150
+ embed (EmbedArg, optional): The embed argument to apply.
151
+
152
+ Returns:
153
+ The merged parameters with the projection and embed applied, in the same shape as the input `params`.
154
+ """
155
+ paths, sizes = normalize_embed(embed)
156
+ extra = [("projection", p) for p in normalize_projection(projection)]
157
+ extra += [("embed", p) for p in paths]
158
+ extra += [("embed.size", f"{p}={n}") for p, n in sizes.items()]
159
+ return _merge(params, extra)
160
+
161
+
162
+ def view_params(params, config=None, all_languages: Optional[bool] = None,
163
+ projection: ProjectionArg = None, embed: EmbedArg = None):
164
+ """
165
+ params + the caller's projection/embed + allLanguages if the config
166
+ (per-call value > languages() scope > default) asks for it.
167
+
168
+ Parameters:
169
+ params (None | dict | list of tuples): The existing parameters to merge into.
170
+ config (optional): The configuration object that may influence the allLanguages setting.
171
+ all_languages (Optional[bool], optional): Whether to include all languages.
172
+ projection (ProjectionArg, optional): The projection argument to apply.
173
+ embed (EmbedArg, optional): The embed argument to apply.
174
+
175
+ Returns:
176
+ The merged parameters with the projection and embed applied, including allLanguages if requested.
177
+ """
178
+ resolve = getattr(config, "use_all_languages", None)
179
+ wants_all = resolve(all_languages) if resolve else bool(all_languages)
180
+ projections = normalize_projection(projection)
181
+ if wants_all and ALL_LANGUAGES not in projections:
182
+ projections.append(ALL_LANGUAGES)
183
+ return apply_view(params, projections, embed)
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Reading embedded results
188
+ # ---------------------------------------------------------------------------
189
+
190
+ def _is_page(value) -> bool:
191
+ """Check if the given value represents a paginated embedded resource.
192
+
193
+ Parameters:
194
+ value (Any): The value to check.
195
+
196
+ Returns:
197
+ bool: True if the value represents a paginated embedded resource, False otherwise.
198
+ """
199
+ return isinstance(value, dict) and "page" in value and isinstance(value.get("_embedded"), dict)
200
+
201
+
202
+ def _page_items(page: Dict[str, Any]) -> List[Dict[str, Any]]:
203
+ """Extract the list of items from a paginated embedded resource.
204
+ Parameters:
205
+ page (Dict[str, Any]): The paginated embedded resource.
206
+
207
+ Returns:
208
+ List[Dict[str, Any]]: The list of items contained in the paginated resource, or an empty list if none are found.
209
+ """
210
+ for value in page["_embedded"].values():
211
+ if isinstance(value, list):
212
+ return value
213
+ return []
214
+
215
+
216
+ def get_embedded_page(resource: Optional[Dict[str, Any]], rel: str
217
+ ) -> Tuple[Optional[List[Dict[str, Any]]], Optional[int]]:
218
+ """
219
+ Get the embedded page for the given relationship key `rel` from the `resource`.
220
+
221
+ (items, totalElements) of an embedded list on `resource`, or (None, None)
222
+ if it was not embedded. totalElements is None when DSpace sent no page info.
223
+
224
+ Parameters:
225
+ resource (Optional[Dict[str, Any]]): The resource containing the embedded list.
226
+ rel (str): The relationship key for the embedded list.
227
+
228
+ Returns:
229
+ Tuple[Optional[List[Dict[str, Any]]], Optional[int]]: A tuple containing the list of embedded items and the total number of elements, or (None, None) if not embedded.
230
+ """
231
+ value = ((resource or {}).get("_embedded") or {}).get(rel)
232
+ if value is None:
233
+ return None, None
234
+ if isinstance(value, list):
235
+ return value, None
236
+ if _is_page(value):
237
+ return _page_items(value), (value.get("page") or {}).get("totalElements")
238
+ return None, None
239
+
240
+
241
+ def is_embedded_complete(resource: Optional[Dict[str, Any]], rel: str) -> bool:
242
+ """True if the embedded list `rel` holds all its elements (not truncated by paging).
243
+
244
+ Parameters:
245
+ resource (Optional[Dict[str, Any]]): The resource containing the embedded list.
246
+ rel (str): The relationship key for the embedded list.
247
+
248
+ Returns:
249
+ bool: True if the embedded list holds all its elements, False otherwise.
250
+ """
251
+ items, total = get_embedded_page(resource, rel)
252
+ if items is None:
253
+ return False
254
+ return total is None or len(items) >= total
255
+
256
+
257
+ def get_embedded(resource: Optional[Dict[str, Any]], path: str, default=None):
258
+ """
259
+ Follow an embed path through `_embedded`.
260
+
261
+ Returns the embedded object for single links (owningCollection), a list
262
+ for list links (bundles), and a flat list when a list appears anywhere on
263
+ the path (bundles/bitstreams -> all bitstreams of all bundles).
264
+ `default` is returned when the first step was not embedded.
265
+
266
+ Parameters:
267
+ resource (Optional[Dict[str, Any]]): The resource to traverse.
268
+ path (str): The embed path to follow through `_embedded`.
269
+ default (Any, optional): The value to return if the first step is not embedded.
270
+
271
+ Returns:
272
+ The embedded object, a list of embedded items, or the default value if not found.
273
+ """
274
+ nodes: List[Any] = [resource]
275
+ is_list = False
276
+ for step, rel in enumerate(_clean_path(path).split("/")):
277
+ found = []
278
+ for node in nodes:
279
+ value = ((node or {}).get("_embedded") or {}).get(rel)
280
+ if value is None:
281
+ continue
282
+ if isinstance(value, list) or _is_page(value):
283
+ is_list = True
284
+ found.extend(value if isinstance(value, list) else _page_items(value))
285
+ else:
286
+ found.append(value)
287
+ if step == 0 and not found and not is_list:
288
+ return default
289
+ nodes = found
290
+ if is_list:
291
+ return nodes
292
+ return nodes[0] if nodes else default