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,920 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ Discovery (search, facets) and browse for DSpace API (DSpace 7+ / DSpace-CRIS)
4
+
5
+ GET /discover/search search configuration: filters, operators, sort options
6
+ GET /discover/search/objects search (query, dsoType, scope, configuration, f.<filter>, sort)
7
+ GET /discover/search/facets facets with their top values for a query
8
+ GET /discover/facets facet names of a configuration
9
+ GET /discover/facets/{name} values of one facet (paged; prefix, sort=count|index)
10
+ GET /discover/browses[/{index}] browse indexes
11
+ GET /discover/browses/search/byFields browse index configured for metadata fields
12
+ GET /discover/browses/{index}/entries 1st level of a metadata browse (authors, subjects, ...)
13
+ GET /discover/browses/{index}/items items of a flat browse, or of one entry
14
+
15
+ d = client.discovery
16
+ for item in d.iter_search("dc.title:climate*", dso_type="item", scope=coll,
17
+ filters={"author": "Doe, Jane", "dateIssued": ("[2020 TO 2026]", "equals")}):
18
+ ...
19
+ d.facet_counts("subject", scope=coll) # {"Climate": 12, ...} - all values, paged
20
+ for entry in d.iter_browse_entries("author"): # every author with item counts
21
+ items = list(d.iter_browse_items("author", value=entry["value"]))
22
+
23
+ Filters use the contract syntax f.<name>=<value>,<operator>; names and
24
+ operators come from get_search_config() (operators: equals, notequals,
25
+ authority, notauthority, contains, notcontains, query).
26
+
27
+ The methods further down (search_objects, find_by_*, get_all_results, ...)
28
+ are the original simple helpers and keep their behaviour.
29
+ """
30
+
31
+ import logging
32
+ from typing import Any, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, Union
33
+
34
+ from ._base import BaseManager
35
+ from .config import Endpoints, with_all_languages
36
+ from .exceptions import handle_response_error
37
+ from .projection import EmbedArg, ProjectionArg
38
+
39
+ # Logger for the module
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # Discovery API endpoints and related constants
43
+ DISCOVER = "/discover"
44
+ OPERATORS = ("equals", "notequals", "authority", "notauthority", "contains", "notcontains", "query")
45
+ DSO_TYPES = ("all", "item", "community", "collection")
46
+
47
+ # {"author": "Doe"} / {"author": ("Doe", "contains")} / [("author", "Doe", "authority"), ...]
48
+ FilterArg = Union[None, Dict[str, Any], Sequence[Tuple[str, ...]]]
49
+
50
+
51
+ def filter_params(filters: FilterArg) -> List[Tuple[str, str]]:
52
+ """
53
+ Discovery filters as query parameters f.<name>=<value>,<operator>.
54
+
55
+ {"author": "Doe, Jane"} -> f.author=Doe, Jane,equals
56
+ {"subject": ["a", "b"]} -> two f.subject parameters (AND)
57
+ {"title": ("rain", "contains")} -> f.title=rain,contains
58
+ [("author", "1234-uuid", "authority")] -> f.author=1234-uuid,authority
59
+ {"dateIssued": "[2020 TO 2026],equals"} -> passed through (operator already given)
60
+
61
+ Parameters:
62
+ filters (FilterArg): The filters to convert into query parameters.
63
+
64
+ Returns:
65
+ List[Tuple[str, str]]: A list of query parameter tuples.
66
+ """
67
+ if not filters:
68
+ return []
69
+ items: List[Tuple[str, Any]] = list(filters.items()) if isinstance(filters, dict) else \
70
+ [(f[0], f[1:] if len(f) > 2 else f[1]) for f in filters]
71
+ params = []
72
+ for name, value in items:
73
+ values = value if isinstance(value, list) else [value]
74
+ for v in values:
75
+ if isinstance(v, tuple):
76
+ text, operator = v[0], (v[1] if len(v) > 1 else "equals")
77
+ else:
78
+ text, operator = v, None
79
+ head, _, tail = str(v).rpartition(",")
80
+ if head and tail in OPERATORS:
81
+ text, operator = head, tail
82
+ operator = operator or "equals"
83
+ if operator not in OPERATORS:
84
+ raise ValueError(f"Unknown filter operator {operator!r}; use one of {OPERATORS}")
85
+ params.append((f"f.{name}", f"{text},{operator}"))
86
+ return params
87
+
88
+
89
+ class DiscoveryManager(BaseManager):
90
+ """Manages DSpace discovery search, facets and browse indexes"""
91
+
92
+ def __init__(self, auth):
93
+ """Initialize the DiscoveryManager with the given authentication."""
94
+ super().__init__(auth)
95
+
96
+ # ==================================================================
97
+ # Search (BaseManager based: retries, paging, projection / embed)
98
+ # ==================================================================
99
+
100
+ def _search_params(self, query=None, dso_type=None, scope=None, configuration=None, filters: FilterArg = None,
101
+ sort=None) -> List[Tuple[str, Any]]:
102
+ """Convert search parameters into query parameters.
103
+
104
+ Parameters:
105
+ query (Optional[str]): The search query.
106
+ dso_type (Optional[str]): The type of DSpace object (e.g., "item", "collection").
107
+ scope (Optional[str]): The scope of the search.
108
+ configuration (Optional[str]): The search configuration to use.
109
+ filters (FilterArg, optional): The filters to apply. Defaults to None.
110
+ sort (Optional[str]): The sort order.
111
+
112
+ Returns:
113
+ List[Tuple[str, Any]]: A list of query parameter tuples.
114
+ """
115
+ if dso_type and str(dso_type).lower() not in DSO_TYPES:
116
+ raise ValueError(f"dso_type must be one of {DSO_TYPES}")
117
+ params = [(k, v) for k, v in (("query", query), ("dsoType", dso_type and str(dso_type).lower()),
118
+ ("scope", scope), ("configuration", configuration), ("sort", sort)) if v]
119
+ return params + filter_params(filters)
120
+
121
+ def get_search_config(self, scope: Optional[str] = None, configuration: Optional[str] = None) -> Dict[str, Any]:
122
+ """
123
+ Get the discovery search configuration.
124
+
125
+ Parameters:
126
+ scope (Optional[str], optional): The scope of the search. Defaults to None.
127
+ configuration (Optional[str], optional): The search configuration to use. Defaults to None.
128
+
129
+ Returns:
130
+ Dict[str, Any]: The search configuration, including filters and sort options.
131
+
132
+ Example response:
133
+ {"filters": [{"filter", "type", "hasFacets", "operators"}], "sortOptions": [...], ...}"""
134
+ return self._get_json(self._url(DISCOVER, "/search"), "Get discovery search configuration",
135
+ params=self._search_params(scope=scope, configuration=configuration))
136
+
137
+ def get_search_filters(self, scope: Optional[str] = None, configuration: Optional[str] = None) -> List[str]:
138
+ """Filter names usable in filters={...}.
139
+
140
+ Parameters:
141
+ scope (Optional[str], optional): The scope of the search. Defaults to None.
142
+ configuration (Optional[str], optional): The search configuration to use. Defaults to None.
143
+
144
+ Returns:
145
+ List[str]: A list of filter names.
146
+ """
147
+ return [f.get("filter") for f in self.get_search_config(scope, configuration).get("filters") or []]
148
+
149
+ def get_sort_options(self, scope: Optional[str] = None, configuration: Optional[str] = None) -> List[Dict[str, Any]]:
150
+ """Get the available sort options for the given scope and configuration.
151
+
152
+ Parameters:
153
+ scope (Optional[str], optional): The scope of the search. Defaults to None.
154
+ configuration (Optional[str], optional): The search configuration to use. Defaults to None.
155
+
156
+ Returns:
157
+ List[Dict[str, Any]]: A list of sort option data.
158
+ """
159
+ return self.get_search_config(scope, configuration).get("sortOptions") or []
160
+
161
+ def search(self, query: Optional[str] = None, dso_type: Optional[str] = None, scope: Optional[str] = None,
162
+ configuration: Optional[str] = None, filters: FilterArg = None, sort: Optional[str] = None,
163
+ page: int = 0, size: int = 20, all_languages: Optional[bool] = None,
164
+ projection: ProjectionArg = None, embed: EmbedArg = None) -> Dict[str, Any]:
165
+ """One page of /discover/search/objects (raw response, incl. facets).
166
+
167
+ Parameters:
168
+ query (Optional[str], optional): The search query. Defaults to None.
169
+ dso_type (Optional[str], optional): The type of DSpace object (e.g., "item", "collection"). Defaults to None.
170
+ scope (Optional[str], optional): The scope of the search. Defaults to None.
171
+ configuration (Optional[str], optional): The search configuration to use. Defaults to None.
172
+ filters (FilterArg, optional): The filters to apply. Defaults to None.
173
+ sort (Optional[str], optional): The sort order. Defaults to None.
174
+ page (int, optional): The page number to retrieve. Defaults to 0.
175
+ size (int, optional): The number of results per page. Defaults to 20.
176
+ all_languages (Optional[bool], optional): Whether to include all languages. Defaults to None.
177
+ projection (ProjectionArg, optional): Projection to apply. Defaults to None.
178
+ embed (EmbedArg, optional): Embed options. Defaults to None.
179
+
180
+ Returns:
181
+ Dict[str, Any]: The raw search response.
182
+ """
183
+ params = self._search_params(query, dso_type, scope, configuration, filters, sort)
184
+ params += [("page", page), ("size", size)]
185
+ return self._get_json(self._url(DISCOVER, "/search/objects"), "Discovery search", params=params,
186
+ all_languages=all_languages, projection=projection, embed=embed)
187
+
188
+ @staticmethod
189
+ def search_result(response: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
190
+ """Get the search result from a /discover/search/objects response.
191
+
192
+ Parameters:
193
+ response (Dict[str, Any]): The raw search response.
194
+
195
+ Returns:
196
+ Tuple[List[Dict[str, Any]], Dict[str, Any]]: A tuple containing the list of objects and the page information.
197
+
198
+ (objects, page) of a /discover/search/objects response."""
199
+ result = (response.get("_embedded") or {}).get("searchResult") or {}
200
+ return (result.get("_embedded") or {}).get("objects") or [], result.get("page") or {}
201
+
202
+ def iter_search(self, query: Optional[str] = None, dso_type: Optional[str] = None, scope: Optional[str] = None,
203
+ configuration: Optional[str] = None, filters: FilterArg = None, sort: Optional[str] = None,
204
+ page_size: int = 100, max_results: Optional[int] = None, with_hits: bool = False,
205
+ all_languages: Optional[bool] = None, projection: ProjectionArg = None,
206
+ embed: EmbedArg = None) -> Iterator[Dict[str, Any]]:
207
+ """
208
+ Every matching object across all pages (a generator). Yields the
209
+ indexableObject; with_hits=True yields the search result wrappers
210
+ (indexableObject in _embedded, hitHighlights) instead.
211
+
212
+ Parameters:
213
+ query (Optional[str], optional): Search query. Defaults to None.
214
+ dso_type (Optional[str], optional): The type of DSpace object to search for. Defaults to None.
215
+ scope (Optional[str], optional): The scope of the search. Defaults to None.
216
+ configuration (Optional[str], optional): The search configuration to use. Defaults to None.
217
+ filters (FilterArg, optional): Filters to apply. Defaults to None.
218
+ sort (Optional[str], optional): Sort order. Defaults to None.
219
+ page_size (int, optional): Number of results per page. Defaults to 100.
220
+ max_results (Optional[int], optional): Maximum number of results to yield. Defaults to None.
221
+ with_hits (bool, optional): Whether to yield search result wrappers instead of indexable objects. Defaults to False.
222
+ all_languages (Optional[bool], optional): Whether to include all languages. Defaults to None.
223
+ projection (ProjectionArg, optional): Projection to apply. Defaults to None.
224
+ embed (EmbedArg, optional): Embed options. Defaults to None.
225
+
226
+ Yields:
227
+ Dict[str, Any]: Each matching object or search result wrapper, depending on with_hits.
228
+ """
229
+ page, yielded, previous = 0, 0, None
230
+ while True:
231
+ response = self.search(query, dso_type, scope, configuration, filters, sort, page, page_size,
232
+ all_languages, projection, embed)
233
+ objects, info = self.search_result(response)
234
+ if objects and objects == previous:
235
+ logger.warning("Discovery returned the same page twice - stopping (server ignores paging?)")
236
+ return
237
+ previous = objects
238
+ for obj in objects:
239
+ found = obj if with_hits else (obj.get("_embedded") or {}).get("indexableObject")
240
+ if not found:
241
+ continue
242
+ yield found
243
+ yielded += 1
244
+ if max_results is not None and yielded >= max_results:
245
+ return
246
+ total_pages = info.get("totalPages")
247
+ if not objects or (total_pages is not None and page + 1 >= total_pages) or \
248
+ (total_pages is None and len(objects) < page_size):
249
+ return
250
+ page += 1
251
+
252
+ def count(self, query: Optional[str] = None, dso_type: Optional[str] = None, scope: Optional[str] = None,
253
+ configuration: Optional[str] = None, filters: FilterArg = None) -> int:
254
+ """Returns the total number of matches for the given query and filters.
255
+
256
+ Parameters:
257
+ query (Optional[str], optional): Search query. Defaults to None.
258
+ dso_type (Optional[str], optional): The type of DSpace object to search for. Defaults to None.
259
+ scope (Optional[str], optional): The scope of the search. Defaults to None.
260
+ configuration (Optional[str], optional): The search configuration to use. Defaults to None.
261
+ filters (FilterArg, optional): Filters to apply. Defaults to None.
262
+
263
+ Returns:
264
+ int: The total number of matches for the given query and filters.
265
+ """
266
+ _, info = self.search_result(self.search(query, dso_type, scope, configuration, filters, size=1))
267
+ return int(info.get("totalElements") or 0)
268
+
269
+ # ------------------------------------------------------------------
270
+ # Facets
271
+ # ------------------------------------------------------------------
272
+
273
+ def search_facets(self, query: Optional[str] = None, dso_type: Optional[str] = None,
274
+ scope: Optional[str] = None, configuration: Optional[str] = None,
275
+ filters: FilterArg = None, names: Optional[Iterable[str]] = None) -> List[Dict[str, Any]]:
276
+ """
277
+ Facets for a query, each with its first page of values in
278
+ facet["_embedded"]["values"] ([{"label", "count", "authorityKey"?}]).
279
+ names: keep only these facets.
280
+
281
+ Parameters:
282
+ query (Optional[str], optional): Search query. Defaults to None.
283
+ dso_type (Optional[str], optional): The type of DSpace object to search for. Defaults to None.
284
+ scope (Optional[str], optional): The scope of the search. Defaults to None.
285
+ configuration (Optional[str], optional): The search configuration to use. Defaults to None.
286
+ filters (FilterArg, optional): Filters to apply. Defaults to None.
287
+ names (Optional[Iterable[str]], optional): Facet names to keep. Defaults to None.
288
+
289
+ Returns:
290
+ List[Dict[str, Any]]: The list of facets with their first page of values.
291
+ """
292
+ params = self._search_params(query, dso_type, scope, configuration, filters)
293
+ data = self._get_json(self._url(DISCOVER, "/search/facets"), "Discovery facets", params=params)
294
+ facets = (data.get("_embedded") or {}).get("facets") or []
295
+ wanted = set(names) if names else None
296
+ return [f for f in facets if wanted is None or f.get("name") in wanted]
297
+
298
+ def list_facets(self, scope: Optional[str] = None, configuration: Optional[str] = None) -> List[Dict[str, Any]]:
299
+ """Facets configured for a scope / configuration (name, facetType, facetLimit, ...), without values.
300
+
301
+ Parameters:
302
+ scope (Optional[str], optional): The scope of the search. Defaults to None.
303
+ configuration (Optional[str], optional): The search configuration to use. Defaults to None.
304
+
305
+ Returns:
306
+ List[Dict[str, Any]]: The list of configured facets without values.
307
+ """
308
+ data = self._get_json(self._url(DISCOVER, "/facets"), "List facets",
309
+ params=self._search_params(scope=scope, configuration=configuration))
310
+ return (data.get("_embedded") or {}).get("facets") or []
311
+
312
+ def get_facet(self, name: str, query: Optional[str] = None, scope: Optional[str] = None,
313
+ configuration: Optional[str] = None, filters: FilterArg = None, dso_type: Optional[str] = None,
314
+ prefix: Optional[str] = None, sort: Optional[str] = None, page: int = 0,
315
+ size: int = 50) -> Dict[str, Any]:
316
+ """
317
+ One page of a facet's values. sort: "count" (default: most frequent
318
+ first) or "index" (alphabetical), optionally ",asc|desc".
319
+ prefix: only values starting with it (DSpace extension used by the UI).
320
+
321
+ Parameters:
322
+ name (str): The name of the facet.
323
+ query (Optional[str], optional): Search query. Defaults to None.
324
+ scope (Optional[str], optional): The scope of the search. Defaults to None.
325
+ configuration (Optional[str], optional): The search configuration to use. Defaults to None.
326
+ filters (FilterArg, optional): Filters to apply. Defaults to None.
327
+ dso_type (Optional[str], optional): The type of DSpace object to search for. Defaults to None.
328
+ prefix (Optional[str], optional): Only values starting with this prefix. Defaults to None.
329
+ sort (Optional[str], optional): Sort order, "count" or "index", optionally with ",asc|desc". Defaults to None.
330
+ page (int, optional): The page number to retrieve. Defaults to 0.
331
+ size (int, optional): The number of values per page. Defaults to 50.
332
+
333
+ Returns:
334
+ Dict[str, Any]: The facet with its values for the specified page.
335
+ """
336
+ params = self._search_params(query, dso_type, scope, configuration, filters, sort)
337
+ if prefix:
338
+ params.append(("prefix", prefix))
339
+ params += [("page", page), ("size", size)]
340
+ return self._get_json(self._url(DISCOVER, f"/facets/{name}"), f"Get facet {name}", params=params)
341
+
342
+ def iter_facet_values(self, name: str, page_size: int = 100, max_results: Optional[int] = None,
343
+ **kwargs) -> Iterator[Dict[str, Any]]:
344
+ """
345
+ All values of a facet ({"label", "count", "authorityKey"?, ...}).
346
+ Facet pages carry no totalPages - paging follows hasMore / the next link.
347
+ kwargs: query, scope, configuration, filters, dso_type, prefix, sort.
348
+
349
+ Parameters:
350
+ name (str): The name of the facet.
351
+ page_size (int, optional): The number of values per page. Defaults to 100.
352
+ max_results (Optional[int], optional): The maximum number of results to yield. Defaults to None.
353
+ **kwargs: Additional arguments passed to `get_facet` (query, scope, configuration, filters, dso_type, prefix, sort).
354
+
355
+ Yields:
356
+ Dict[str, Any]: Each value of the facet.
357
+ """
358
+ page, yielded, previous = 0, 0, None
359
+ while True:
360
+ data = self.get_facet(name, page=page, size=page_size, **kwargs)
361
+ values = (data.get("_embedded") or {}).get("values") or []
362
+ if values and values == previous:
363
+ logger.warning("Facet %s returned the same page twice - stopping", name)
364
+ return
365
+ previous = values
366
+ for value in values:
367
+ yield value
368
+ yielded += 1
369
+ if max_results is not None and yielded >= max_results:
370
+ return
371
+ has_next = data.get("hasMore") or "next" in (data.get("_links") or {})
372
+ if not values or not has_next:
373
+ return
374
+ page += 1
375
+
376
+ def facet_counts(self, name: str, **kwargs) -> Dict[str, int]:
377
+ """Facet counts for a given facet.
378
+
379
+ {label: count} for every value of a facet.
380
+
381
+ Parameters:
382
+ name (str): The name of the facet.
383
+ **kwargs: Additional arguments passed to `iter_facet_values` (query, scope, configuration, filters, dso_type, prefix, sort).
384
+
385
+ Returns:
386
+ Dict[str, int]: A dictionary mapping facet labels to their counts.
387
+ """
388
+ return {v.get("label"): int(v.get("count") or 0) for v in self.iter_facet_values(name, **kwargs)}
389
+
390
+ # ------------------------------------------------------------------
391
+ # Browse
392
+ # ------------------------------------------------------------------
393
+
394
+ def get_browse_indexes(self) -> List[Dict[str, Any]]:
395
+ """
396
+ Get the list of browse indexes.
397
+
398
+ Returns:
399
+ List[Dict[str, Any]]: A list of browse indexes, each represented as a dictionary.
400
+
401
+ [{"id": "author", "browseType": "valueList"|"flatBrowse"|"hierarchicalBrowse", ...}]"""
402
+ return list(self._iter_embedded(self._url(DISCOVER, "/browses"), "browses", "List browse indexes"))
403
+
404
+ def get_browse_index(self, index: str) -> Dict[str, Any]:
405
+ """
406
+ Get the configuration of a specific browse index by its ID.
407
+
408
+ Parameters:
409
+ index (str): The ID of the browse index.
410
+
411
+ Returns:
412
+ Dict[str, Any]: The browse index configuration.
413
+ """
414
+ return self._get_json(self._url(DISCOVER, f"/browses/{index}"), f"Get browse index {index}")
415
+
416
+ def find_browse_index(self, fields: Union[str, Iterable[str]]) -> Optional[Dict[str, Any]]:
417
+ """
418
+ Browse index configured for metadata field(s), e.g. "dc.contributor.author"; None if there is none.
419
+
420
+ Parameters:
421
+ fields (Union[str, Iterable[str]]): The metadata field(s) to search for.
422
+
423
+ Returns:
424
+ Optional[Dict[str, Any]]: The browse index configuration if found, otherwise None.
425
+ """
426
+ fields = [fields] if isinstance(fields, str) else list(fields)
427
+ if not fields:
428
+ raise ValueError("At least one metadata field is required")
429
+ return self._get_optional(self._url(DISCOVER, "/browses/search/byFields"), "Find browse index by fields",
430
+ params=[("fields", f) for f in fields])
431
+
432
+ def _browse_page(self, url: str, key: str, operation: str, params: List[Tuple[str, Any]],
433
+ starts_with: Optional[str], page_size: int, max_results: Optional[int],
434
+ all_languages: Optional[bool] = None, embed: EmbedArg = None) -> Iterator[Dict[str, Any]]:
435
+ """
436
+ Internal helper to iterate over browse pages.
437
+
438
+ Parameters:
439
+ url (str): The URL to fetch the browse page from.
440
+ key (str): The key to extract from the embedded response.
441
+ operation (str): The operation description for logging or error messages.
442
+ params (List[Tuple[str, Any]]): The query parameters for the request.
443
+ starts_with (Optional[str]): The prefix to start browsing from.
444
+ page_size (int): The number of items per page.
445
+ max_results (Optional[int]): The maximum number of results to yield.
446
+ all_languages (Optional[bool]): Whether to include all languages.
447
+ embed (EmbedArg): The embed argument for the request.
448
+
449
+ Yields:
450
+ Dict[str, Any]: Each browse entry.
451
+ """
452
+ if starts_with:
453
+ # DSpace answers 422 when page and startsWith are combined: one page from the prefix on
454
+ size = max_results or page_size
455
+ data = self._get_json(url, operation, params=params + [("startsWith", starts_with), ("size", size)],
456
+ all_languages=all_languages, embed=embed)
457
+ yield from ((data.get("_embedded") or {}).get(key) or [])[:size]
458
+ return
459
+ yield from self._iter_embedded(url, key, operation, params, page_size=page_size, max_results=max_results,
460
+ all_languages=all_languages, embed=embed)
461
+
462
+ def iter_browse_entries(self, index: str, scope: Optional[str] = None, sort: Optional[str] = None,
463
+ starts_with: Optional[str] = None, page_size: int = 100,
464
+ max_results: Optional[int] = None) -> Iterator[Dict[str, Any]]:
465
+ """
466
+ 1st level of a metadata browse ("valueList"): {"value", "authority", "count", ...}.
467
+ starts_with: jump to the entries starting at this prefix (one page).
468
+ sort: "default,asc" / "default,desc".
469
+
470
+ Parameters:
471
+ index (str): The ID of the browse index.
472
+ scope (Optional[str]): The scope of the browse.
473
+ sort (Optional[str]): The sort order, e.g., "default,asc" or "default,desc".
474
+ starts_with (Optional[str]): The prefix to start browsing from.
475
+ page_size (int): The number of items per page.
476
+ max_results (Optional[int]): The maximum number of results to yield.
477
+
478
+ Yields:
479
+ Dict[str, Any]: Each browse entry.
480
+ """
481
+ params = [(k, v) for k, v in (("scope", scope), ("sort", sort)) if v]
482
+ return self._browse_page(self._url(DISCOVER, f"/browses/{index}/entries"), "browseEntries",
483
+ f"Browse entries of {index}", params, starts_with, page_size, max_results)
484
+
485
+ def iter_browse_items(self, index: str, value: Optional[str] = None, authority: Optional[str] = None,
486
+ scope: Optional[str] = None, sort: Optional[str] = None,
487
+ starts_with: Optional[str] = None, page_size: int = 100,
488
+ max_results: Optional[int] = None, all_languages: Optional[bool] = None,
489
+ embed: EmbedArg = None) -> Iterator[Dict[str, Any]]:
490
+ """
491
+ Items of a flat browse (e.g. "title", "dateissued"), or - for a
492
+ metadata browse - of one entry, given by value or authority (not both).
493
+ sort: "<sortOption>,asc|desc" from get_browse_index()["sortOptions"].
494
+
495
+ Parameters:
496
+ index (str): The ID of the browse index.
497
+ value (Optional[str]): The value to filter by.
498
+ authority (Optional[str]): The authority to filter by.
499
+ scope (Optional[str]): The scope of the browse.
500
+ sort (Optional[str]): The sort order, e.g., "default,asc" or "default,desc".
501
+ starts_with (Optional[str]): The prefix to start browsing from.
502
+ page_size (int): The number of items per page.
503
+ max_results (Optional[int]): The maximum number of results to yield.
504
+ all_languages (Optional[bool]): Whether to include all languages.
505
+ embed (EmbedArg): The embed argument for the request.
506
+
507
+ Yields:
508
+ Dict[str, Any]: Each browse item.
509
+ """
510
+ if value is not None and authority is not None:
511
+ raise ValueError("Pass value or authority, not both")
512
+ params = [(k, v) for k, v in (("filterValue", value), ("filterAuthority", authority),
513
+ ("scope", scope), ("sort", sort)) if v is not None]
514
+ return self._browse_page(self._url(DISCOVER, f"/browses/{index}/items"), "items", f"Browse items of {index}",
515
+ params, starts_with, page_size, max_results, all_languages, embed)
516
+
517
+ def iter_entry_items(self, entry: Dict[str, Any], page_size: int = 100, max_results: Optional[int] = None,
518
+ all_languages: Optional[bool] = None) -> Iterator[Dict[str, Any]]:
519
+ """Items of a browse entry by following its "items" link (as the contract recommends).
520
+
521
+ Parameters:
522
+ entry (Dict[str, Any]): The browse entry containing the "items" link.
523
+ page_size (int): The number of items per page.
524
+ max_results (Optional[int]): The maximum number of results to yield.
525
+ all_languages (Optional[bool]): Whether to include all languages.
526
+
527
+ Yields:
528
+ Dict[str, Any]: Each browse item.
529
+ """
530
+ href = ((entry.get("_links") or {}).get("items") or {}).get("href")
531
+ if not href:
532
+ raise ValueError("Browse entry has no items link")
533
+ return self._iter_embedded(self.resolve_href(href), "items", "Browse entry items",
534
+ page_size=page_size, max_results=max_results, all_languages=all_languages)
535
+
536
+ # ==================================================================
537
+ # Original helpers
538
+ # ==================================================================
539
+
540
+ def get_total_and_objects(self, results):
541
+ """Return (total_elements, objects) from a discovery response with DSpace's nested structure.
542
+
543
+ Parameters:
544
+ results (dict): The discovery response.
545
+
546
+ Returns:
547
+ Tuple[int, List[dict]]: Total elements and list of objects.
548
+ """
549
+ objects = (results.get('_embedded', {})
550
+ .get('searchResult', {})
551
+ .get('_embedded', {})
552
+ .get('objects', []))
553
+ total_elements = (results.get('_embedded', {})
554
+ .get('searchResult', {})
555
+ .get('page', {})
556
+ .get('totalElements', len(objects)))
557
+ return total_elements, objects
558
+
559
+
560
+ def search_objects(self, query, page=0, size=10, sort="score,DESC",
561
+ projection="preventMetadataSecurity", scope=None,
562
+ filters=None, all_languages=None):
563
+ """
564
+ Perform a discovery search query
565
+
566
+ Parameters:
567
+ query: Search query string
568
+ page: Page number (0-based)
569
+ size: Number of results per page
570
+ sort: Sort criteria (default: "score,DESC")
571
+ projection: Projection type
572
+ scope: Optional scope UUID (collection, community)
573
+ filters: Optional list of filter queries
574
+ all_languages: add projection=allLanguages (combined with `projection`);
575
+ None = client setting
576
+
577
+ Returns:
578
+ dict: Search results
579
+ """
580
+ logger.info("Running discovery query: %s", query)
581
+
582
+ url = f"{self.config.dspace_url}{Endpoints.DISCOVER_SEARCH}"
583
+
584
+ params = {
585
+ "query": query,
586
+ "sort": sort,
587
+ "page": page,
588
+ "size": size,
589
+ "projection": projection
590
+ }
591
+
592
+ if scope:
593
+ params["scope"] = scope
594
+
595
+ if filters:
596
+ for i, filter_query in enumerate(filters):
597
+ params[f"f.{i}"] = filter_query
598
+
599
+ params = with_all_languages(params, self.config.use_all_languages(all_languages))
600
+
601
+ try:
602
+ response = self.session.get(
603
+ url,
604
+ headers=self.config.headers,
605
+ params=params,
606
+ timeout=self.config.timeout
607
+ )
608
+
609
+ if response.status_code == 200:
610
+ logger.info("Search completed successfully, found results")
611
+ return response.json()
612
+ handle_response_error(response, "Discovery search")
613
+
614
+ except Exception as e:
615
+ logger.error(f"Discovery search failed: {e}", exc_info=True)
616
+ raise
617
+
618
+ def find_by_metadata(self, field, value, entity_type=None, exact_match=True):
619
+ """
620
+ Search for objects by a specific metadata field
621
+
622
+ Parameters:
623
+ field: Metadata field name (e.g., "dc.title", "dc.creator")
624
+ value: Value to search for
625
+ entity_type: Optional entity type filter
626
+ exact_match: Whether to search for exact matches
627
+
628
+ Returns:
629
+ dict: Search results
630
+ """
631
+ if exact_match:
632
+ query = f'{field}:"{value}"'
633
+ else:
634
+ query = f'{field}:{value}'
635
+
636
+ if entity_type:
637
+ query += f' AND entityType:"{entity_type}"'
638
+
639
+ return self.search_objects(query)
640
+
641
+ def find_by_title(self, title, entity_type=None, exact_match=True):
642
+ """
643
+ Search for objects by title
644
+
645
+ Parameters:
646
+ title: Title to search for
647
+ entity_type: Optional entity type filter
648
+ exact_match: Whether to search for exact matches
649
+
650
+ Returns:
651
+ dict: Search results
652
+ """
653
+ return self.find_by_metadata("dc.title", title, entity_type, exact_match)
654
+
655
+ def find_by_author(self, author_name, entity_type="Publication", exact_match=False):
656
+ """
657
+ Search for publications by author name
658
+
659
+ Parameters:
660
+ author_name: Author name to search for
661
+ entity_type: Entity type (default: "Publication")
662
+ exact_match: Whether to search for exact matches
663
+
664
+ Returns:
665
+ dict: Search results
666
+ """
667
+ return self.find_by_metadata("dc.contributor.author", author_name, entity_type, exact_match)
668
+
669
+ def find_persons(self, name, exact_match=False):
670
+ """
671
+ Search for Person entities
672
+
673
+ Parameters:
674
+ name: Person name to search for
675
+ exact_match: Whether to search for exact matches
676
+
677
+ Returns:
678
+ dict: Search results
679
+ """
680
+ # Try both family name and given name fields
681
+ if exact_match:
682
+ query = f'(person.familyName:"{name}" OR person.givenName:"{name}" OR dc.title:"{name}") AND entityType:"Person"'
683
+ else:
684
+ query = f'(person.familyName:{name} OR person.givenName:{name} OR dc.title:{name}) AND entityType:"Person"'
685
+
686
+ return self.search_objects(query)
687
+
688
+ def find_by_handle(self, handle):
689
+ """
690
+ Find an object by its handle
691
+
692
+ Parameters:
693
+ handle: DSpace handle (e.g., "123456789/123")
694
+
695
+ Returns:
696
+ dict: Search results
697
+ """
698
+ query = f'handle:"{handle}"'
699
+ return self.search_objects(query, size=1)
700
+
701
+ def find_by_uuid(self, uuid):
702
+ """
703
+ Find an object by its UUID
704
+
705
+ Parameters:
706
+ uuid: Object UUID
707
+
708
+ Returns:
709
+ dict: Search results
710
+ """
711
+ query = f'uuid:"{uuid}"'
712
+ return self.search_objects(query, size=1)
713
+
714
+ def find_recent_items(self, entity_type=None, days=30, size=20):
715
+ """
716
+ Find recently added items
717
+
718
+ Parameters:
719
+ entity_type: Optional entity type filter
720
+ days: Number of days back to search
721
+ size: Number of results to return
722
+
723
+ Returns:
724
+ dict: Search results
725
+ """
726
+ query = "*:*"
727
+ if entity_type:
728
+ query += f' AND entityType:"{entity_type}"'
729
+
730
+ # Sort by last modified or date accessioned
731
+ sort = "lastModified,DESC"
732
+
733
+ return self.search_objects(query, size=size, sort=sort)
734
+
735
+ def search_in_collection(self, collection_uuid, query="*:*", **kwargs):
736
+ """
737
+ Search within a specific collection
738
+
739
+ Parameters:
740
+ collection_uuid: UUID of the collection to search in
741
+ query: Search query (default: "*:*" for all items)
742
+ **kwargs: Additional search parameters
743
+
744
+ Returns:
745
+ dict: Search results
746
+ """
747
+ return self.search_objects(query, scope=collection_uuid, **kwargs)
748
+
749
+ def search_in_community(self, community_uuid, query="*:*", **kwargs):
750
+ """
751
+ Search within a specific community
752
+
753
+ Parameters:
754
+ community_uuid: UUID of the community to search in
755
+ query: Search query (default: "*:*" for all items)
756
+ **kwargs: Additional search parameters
757
+
758
+ Returns:
759
+ dict: Search results
760
+ """
761
+ return self.search_objects(query, scope=community_uuid, **kwargs)
762
+
763
+ def get_facets(self, query="*:*", facet_fields=None, scope=None, configuration=None, filters: FilterArg = None):
764
+ """
765
+ Facets for a query: {"_embedded": {"facets": [...]}} from
766
+ /discover/search/facets, limited to facet_fields (facet names such as
767
+ "author", "subject", "dateIssued") when given. See search_facets().
768
+
769
+ Parameters:
770
+ query (str): Search query (default: "*:*" for all items)
771
+ facet_fields (list, optional): List of facet field names to include
772
+ scope (str, optional): Scope for the search (collection or community UUID)
773
+ configuration (str, optional): Search configuration
774
+ filters (FilterArg, optional): Filters to apply
775
+
776
+ Returns:
777
+ dict: Faceted search results
778
+ """
779
+ facets = self.search_facets(query=query, scope=scope, configuration=configuration, filters=filters,
780
+ names=facet_fields)
781
+ return {"_embedded": {"facets": facets}}
782
+
783
+ def parse_search_results(self, results):
784
+ """
785
+ Parse search results into a more convenient format
786
+
787
+ Parameters:
788
+ results (dict): Raw search results from DSpace API
789
+
790
+ Returns:
791
+ list: List of parsed objects
792
+ """
793
+ objects = results.get("_embedded", {}).get("searchResult", {}).get("_embedded", {}).get("objects", [])
794
+ parsed_objects = []
795
+
796
+ for obj in objects:
797
+ embedded = obj.get("_embedded", {})
798
+ item = embedded.get("indexableObject", {})
799
+
800
+ parsed = {
801
+ "uuid": item.get("uuid"),
802
+ "name": item.get("name"),
803
+ "handle": item.get("handle"),
804
+ "type": item.get("type"),
805
+ "entityType": item.get("entityType"),
806
+ "lastModified": item.get("lastModified"),
807
+ "metadata": {}
808
+ }
809
+
810
+ # Extract common metadata fields
811
+ metadata = item.get("metadata", {})
812
+ common_fields = [
813
+ "dc.title", "dc.creator", "dc.contributor.author", "dc.date.issued",
814
+ "dc.type", "dc.description", "dc.subject", "dc.identifier.uri",
815
+ "person.familyName", "person.givenName", "person.identifier.gnd"
816
+ ]
817
+
818
+ for field in common_fields:
819
+ if field in metadata:
820
+ values = metadata[field]
821
+ if values:
822
+ parsed["metadata"][field] = [v.get("value") for v in values if v.get("value")]
823
+
824
+ # Add title as separate field for convenience
825
+ if "dc.title" in parsed["metadata"] and parsed["metadata"]["dc.title"]:
826
+ parsed["title"] = parsed["metadata"]["dc.title"][0]
827
+
828
+ # Add author/creator as separate field
829
+ author_fields = ["dc.contributor.author", "dc.creator"]
830
+ for field in author_fields:
831
+ if field in parsed["metadata"] and parsed["metadata"][field]:
832
+ parsed["author"] = parsed["metadata"][field][0]
833
+ break
834
+
835
+ # Add person name for Person entities
836
+ if parsed.get("entityType") == "Person":
837
+ family_name = parsed["metadata"].get("person.familyName", [""])[0]
838
+ given_name = parsed["metadata"].get("person.givenName", [""])[0]
839
+ if family_name or given_name:
840
+ parsed["person_name"] = f"{family_name}, {given_name}".strip(", ")
841
+
842
+ parsed_objects.append(parsed)
843
+
844
+ return parsed_objects
845
+
846
+ def count_results(self, query):
847
+ """
848
+ Get the total count of results for a query without retrieving all results
849
+
850
+ Parameters:
851
+ query (str): The search query to count results for.
852
+
853
+ Returns:
854
+ int: Total number of results matching the query.
855
+ """
856
+ results = self.search_objects(query, size=1)
857
+ total, _ = self.get_total_and_objects(results)
858
+ return total
859
+
860
+
861
+ def get_all_results(self, query: str, max_results: int | None = None, size: int = 20,
862
+ all_languages: bool | None = None) -> list[dict]:
863
+ """
864
+ Fetch all discovery results across pages.
865
+ - Supports 'page' metadata both at root and under _embedded.searchResult.page
866
+ - Stops before requesting an empty, beyond-last page (so tests expecting 2 calls pass)
867
+
868
+ Parameters:
869
+ query (str): The search query to fetch results for.
870
+ max_results (int | None, optional): Maximum number of results to fetch. Defaults to None.
871
+ size (int, optional): Number of results per page. Defaults to 20.
872
+ all_languages (bool | None, optional): Whether to include all languages. Defaults to None.
873
+
874
+ Returns:
875
+ list[dict]: List of discovery result objects.
876
+ """
877
+ results: list[dict] = []
878
+ page = 0
879
+
880
+ def _extract_page_info(res: dict) -> tuple[int | None, int | None]:
881
+ """
882
+ Extract page number and total pages from the response.
883
+
884
+ Parameters:
885
+ res (dict): The raw search response from DSpace API.
886
+
887
+ Returns:
888
+ tuple[int | None, int | None]: Current page number and total pages, if available.
889
+ """
890
+ page_block = res.get("page") or res.get("_embedded", {}).get("searchResult", {}).get("page") or {}
891
+ number = page_block.get("number")
892
+ total_pages = page_block.get("totalPages")
893
+ return number, total_pages
894
+
895
+ while True:
896
+ res = self.search_objects(query, page=page, size=size, all_languages=all_languages)
897
+ # Use helper to get the list of search objects
898
+ _total, objects = self.get_total_and_objects(res)
899
+
900
+ # Unwrap indexableObject and collect
901
+ for obj in (objects or []):
902
+ item = obj.get("_embedded", {}).get("indexableObject", {})
903
+ if item:
904
+ results.append(item)
905
+ if max_results is not None and len(results) >= max_results:
906
+ return results[:max_results]
907
+
908
+ # Pagination decision
909
+ number, total_pages = _extract_page_info(res)
910
+
911
+ # If we have valid page metadata and we're on the last page, stop without extra fetch
912
+ if number is not None and total_pages is not None and number >= (total_pages - 1):
913
+ break
914
+
915
+ # Otherwise, advance to next page; if there were no objects, also stop to avoid a loop
916
+ if not objects:
917
+ break
918
+ page += 1
919
+
920
+ return results