albert 1.14.1__py3-none-any.whl → 1.16.0__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.
albert/__init__.py CHANGED
@@ -4,4 +4,4 @@ from albert.core.auth.sso import AlbertSSOClient
4
4
 
5
5
  __all__ = ["Albert", "AlbertClientCredentials", "AlbertSSOClient"]
6
6
 
7
- __version__ = "1.14.1"
7
+ __version__ = "1.16.0"
@@ -9,7 +9,7 @@ from pydantic import validate_call
9
9
  from albert.collections.base import BaseCollection
10
10
  from albert.collections.files import FileCollection
11
11
  from albert.collections.notes import NotesCollection
12
- from albert.core.shared.identifiers import AttachmentId, DataColumnId, InventoryId
12
+ from albert.core.shared.identifiers import AttachmentId, DataColumnId, InventoryId, ProjectId
13
13
  from albert.core.shared.types import MetadataItem
14
14
  from albert.resources.attachments import Attachment, AttachmentCategory
15
15
  from albert.resources.files import FileCategory, FileNamespace
@@ -288,3 +288,47 @@ class AttachmentCollection(BaseCollection):
288
288
 
289
289
  response = self.session.post(self.base_path, json=payload)
290
290
  return Attachment(**response.json())
291
+
292
+ @validate_call
293
+ def upload_and_attach_document_to_project(
294
+ self,
295
+ *,
296
+ project_id: ProjectId,
297
+ file_path: Path,
298
+ ) -> Attachment:
299
+ """Upload a file and attach it as a document to a project.
300
+
301
+ Args:
302
+ project_id: The Albert ID of the project (e.g. "PRO770").
303
+ file_path: Local path to the file to upload.
304
+
305
+ Returns:
306
+ The created Attachment record.
307
+ """
308
+ resolved_path = file_path.expanduser()
309
+ if not resolved_path.is_file():
310
+ raise FileNotFoundError(f"File not found at '{resolved_path}'")
311
+
312
+ content_type = mimetypes.guess_type(resolved_path.name)[0] or "application/octet-stream"
313
+ encoded_file_name = quote(resolved_path.name)
314
+ file_key = f"{project_id}/documents/original/{encoded_file_name}"
315
+
316
+ file_collection = self._get_file_collection()
317
+ with resolved_path.open("rb") as file_handle:
318
+ file_collection.sign_and_upload_file(
319
+ data=file_handle,
320
+ name=file_key,
321
+ namespace=FileNamespace.RESULT,
322
+ content_type=content_type,
323
+ )
324
+
325
+ payload = {
326
+ "parentId": project_id,
327
+ "category": AttachmentCategory.OTHER.value,
328
+ "name": encoded_file_name,
329
+ "key": file_key,
330
+ "nameSpace": FileNamespace.RESULT.value,
331
+ }
332
+
333
+ response = self.session.post(self.base_path, json=payload)
334
+ return Attachment(**response.json())
@@ -7,6 +7,7 @@ from albert.core.pagination import AlbertPaginator
7
7
  from albert.core.session import AlbertSession
8
8
  from albert.core.shared.enums import OrderBy, PaginationMode
9
9
  from albert.core.shared.identifiers import BTInsightId
10
+ from albert.core.utils import ensure_list
10
11
  from albert.resources.btinsight import BTInsight, BTInsightCategory, BTInsightState
11
12
 
12
13
 
@@ -132,17 +133,17 @@ class BTInsightCollection(BaseCollection):
132
133
  """
133
134
  params = {
134
135
  "offset": offset,
135
- "order": OrderBy(order_by).value if order_by else None,
136
+ "order": order_by,
136
137
  "sortBy": sort_by,
137
138
  "text": text,
138
- "name": name,
139
+ "name": ensure_list(name),
139
140
  }
140
- if state:
141
- state = state if isinstance(state, list) else [state]
142
- params["state"] = [BTInsightState(x).value for x in state]
143
- if category:
144
- category = category if isinstance(category, list) else [category]
145
- params["category"] = [BTInsightCategory(x).value for x in category]
141
+
142
+ state_values = ensure_list(state)
143
+ params["state"] = state_values if state_values else None
144
+
145
+ category_values = ensure_list(category)
146
+ params["category"] = category_values if category_values else None
146
147
 
147
148
  return AlbertPaginator(
148
149
  mode=PaginationMode.OFFSET,
albert/collections/cas.py CHANGED
@@ -106,7 +106,7 @@ class CasCollection(BaseCollection):
106
106
  An iterator over Cas entities.
107
107
  """
108
108
 
109
- params: dict[str, Any] = {"orderBy": order_by.value}
109
+ params: dict[str, Any] = {"orderBy": order_by}
110
110
  if id is not None:
111
111
  yield self.get_by_id(id=id)
112
112
  return
@@ -7,6 +7,7 @@ from albert.core.logging import logger
7
7
  from albert.core.pagination import AlbertPaginator, PaginationMode
8
8
  from albert.core.session import AlbertSession
9
9
  from albert.core.shared.identifiers import CompanyId
10
+ from albert.core.utils import ensure_list
10
11
  from albert.exceptions import AlbertException
11
12
  from albert.resources.companies import Company
12
13
 
@@ -62,9 +63,8 @@ class CompanyCollection(BaseCollection):
62
63
  "dupDetection": "false",
63
64
  "startKey": start_key,
64
65
  }
65
- if name:
66
- params["name"] = name if isinstance(name, list) else [name]
67
- params["exactMatch"] = str(exact_match).lower()
66
+ params["name"] = ensure_list(name)
67
+ params["exactMatch"] = str(exact_match).lower()
68
68
 
69
69
  return AlbertPaginator(
70
70
  mode=PaginationMode.KEY,
@@ -3,13 +3,20 @@ from collections.abc import Iterator
3
3
  from pydantic import validate_call
4
4
 
5
5
  from albert.collections.base import BaseCollection
6
+ from albert.collections.tags import TagCollection
6
7
  from albert.core.logging import logger
7
8
  from albert.core.pagination import AlbertPaginator
8
9
  from albert.core.session import AlbertSession
9
- from albert.core.shared.enums import PaginationMode
10
+ from albert.core.shared.enums import OrderBy, PaginationMode, Status
10
11
  from albert.core.shared.identifiers import CustomTemplateId
11
- from albert.exceptions import AlbertHTTPError
12
- from albert.resources.custom_templates import CustomTemplate, CustomTemplateSearchItem
12
+ from albert.core.shared.models.patch import PatchOperation
13
+ from albert.core.utils import ensure_list
14
+ from albert.resources.acls import ACL
15
+ from albert.resources.custom_templates import (
16
+ CustomTemplate,
17
+ CustomTemplateSearchItem,
18
+ TemplateCategory,
19
+ )
13
20
 
14
21
 
15
22
  class CustomTemplatesCollection(BaseCollection):
@@ -31,6 +38,102 @@ class CustomTemplatesCollection(BaseCollection):
31
38
  self.base_path = f"/api/{CustomTemplatesCollection._api_version}/customtemplates"
32
39
 
33
40
  @validate_call
41
+ def create(
42
+ self,
43
+ *,
44
+ custom_template: CustomTemplate | list[CustomTemplate],
45
+ ) -> list[CustomTemplate]:
46
+ """
47
+ Creates one or more custom templates.
48
+
49
+ Parameters
50
+ ----------
51
+ custom_template : CustomTemplate | list[CustomTemplate]
52
+ The template entities to create.
53
+
54
+ Returns
55
+ -------
56
+ list[CustomTemplate]
57
+ The created CustomTemplate entities.
58
+ """
59
+ templates = ensure_list(custom_template) or []
60
+ if len(templates) == 0:
61
+ raise ValueError("At least one CustomTemplate must be provided.")
62
+ if len(templates) > 10:
63
+ raise ValueError("A maximum of 10 CustomTemplates can be created at once.")
64
+
65
+ payload = [
66
+ template.model_dump(
67
+ mode="json",
68
+ by_alias=True,
69
+ exclude_none=True,
70
+ exclude_unset=True,
71
+ )
72
+ for template in templates
73
+ ]
74
+ response = self.session.post(url=self.base_path, json=payload)
75
+ response_data = response.json()
76
+ created_payloads = (
77
+ (response_data or {}).get("CreatedItems")
78
+ if response.status_code == 206
79
+ else response_data
80
+ ) or []
81
+
82
+ tag_collection = TagCollection(session=self.session)
83
+
84
+ def resolve_tag(tag_id: str | None) -> dict[str, str] | None:
85
+ if not tag_id:
86
+ return None
87
+ tag = tag_collection.get_by_id(id=tag_id)
88
+ return {"albertId": tag.id or tag_id, "name": tag.tag}
89
+
90
+ def populate_tag_names(section: dict | None) -> None:
91
+ if not isinstance(section, dict):
92
+ return
93
+ tags = section.get("Tags")
94
+ if not tags:
95
+ return
96
+ resolved_tags = []
97
+ for tag in tags:
98
+ if isinstance(tag, dict):
99
+ tag_id = tag.get("id") or tag.get("albertId")
100
+ elif isinstance(tag, str):
101
+ tag_id = tag
102
+ else:
103
+ tag_id = None
104
+
105
+ resolved_tag = resolve_tag(tag_id)
106
+ if resolved_tag:
107
+ resolved_tags.append(resolved_tag)
108
+ section["Tags"] = resolved_tags
109
+
110
+ for payload in created_payloads:
111
+ if not isinstance(payload, dict):
112
+ continue
113
+ populate_tag_names(payload.get("Data"))
114
+
115
+ if response.status_code == 206:
116
+ failed_items = response_data.get("FailedItems") or []
117
+ if failed_items:
118
+ error_messages = []
119
+ for failed in failed_items:
120
+ errors = failed.get("errors") or []
121
+ if errors:
122
+ error_messages.extend(err.get("msg", "Unknown error") for err in errors)
123
+ joined = " | ".join(error_messages) if error_messages else "Unknown error"
124
+ logger.warning(
125
+ "Custom template creation partially succeeded. Errors: %s",
126
+ joined,
127
+ )
128
+
129
+ hydrated_templates = []
130
+ for payload in created_payloads:
131
+ template = CustomTemplate(**payload)
132
+ hydrated = self.get_by_id(id=template.id)
133
+ hydrated_templates.append(hydrated)
134
+
135
+ return hydrated_templates
136
+
34
137
  def get_by_id(self, *, id: CustomTemplateId) -> CustomTemplate:
35
138
  """Get a Custom Template by ID
36
139
 
@@ -52,8 +155,20 @@ class CustomTemplatesCollection(BaseCollection):
52
155
  self,
53
156
  *,
54
157
  text: str | None = None,
55
- max_items: int | None = None,
56
158
  offset: int | None = 0,
159
+ sort_by: str | None = None,
160
+ order_by: OrderBy | None = None,
161
+ status: Status | None = None,
162
+ created_by: str | None = None,
163
+ category: TemplateCategory | list[TemplateCategory] | None = None,
164
+ created_by_name: str | list[str] | None = None,
165
+ collaborator: str | list[str] | None = None,
166
+ facet_text: str | None = None,
167
+ facet_field: str | None = None,
168
+ contains_field: str | list[str] | None = None,
169
+ contains_text: str | list[str] | None = None,
170
+ my_role: str | list[str] | None = None,
171
+ max_items: int | None = None,
57
172
  ) -> Iterator[CustomTemplateSearchItem]:
58
173
  """
59
174
  Search for CustomTemplate matching the provided criteria.
@@ -64,20 +179,57 @@ class CustomTemplatesCollection(BaseCollection):
64
179
  Parameters
65
180
  ----------
66
181
  text : str, optional
67
- Text to filter search results by.
68
- max_items : int, optional
69
- Maximum number of items to return in total. If None, fetches all available items.
182
+ Free text search term.
70
183
  offset : int, optional
71
- Offset to begin pagination at. Default is 0.
184
+ Starting offset for pagination.
185
+ sort_by : str, optional
186
+ Field to sort on.
187
+ order_by : OrderBy, optional
188
+ Sort direction for `sort_by`.
189
+ status : Status | str, optional
190
+ Filter results by template status.
191
+ created_by : str, optional
192
+ Filter by creator id.
193
+ category : TemplateCategory | list[TemplateCategory], optional
194
+ Filter by template categories.
195
+ created_by_name : str | list[str], optional
196
+ Filter by creator display name(s).
197
+ collaborator : str | list[str], optional
198
+ Filter by collaborator ids.
199
+ facet_text : str, optional
200
+ Filter text within a facet.
201
+ facet_field : str, optional
202
+ Facet field to search inside.
203
+ contains_field : str | list[str], optional
204
+ Fields to apply contains search to.
205
+ contains_text : str | list[str], optional
206
+ Text values for contains search.
207
+ my_role : str | list[str], optional
208
+ Restrict templates to roles held by the calling user.
209
+ max_items : int, optional
210
+ Maximum number of items to yield client-side.
72
211
 
73
212
  Returns
74
213
  -------
75
214
  Iterator[CustomTemplateSearchItem]
76
215
  An iterator of CustomTemplateSearchItem items.
77
216
  """
217
+
78
218
  params = {
79
219
  "text": text,
80
220
  "offset": offset,
221
+ "sortBy": sort_by,
222
+ "order": order_by,
223
+ "status": status,
224
+ "createdBy": created_by,
225
+ "category": ensure_list(category),
226
+ "createdByName": ensure_list(created_by_name),
227
+ "collaborator": ensure_list(collaborator),
228
+ "facetText": facet_text,
229
+ "facetField": facet_field,
230
+ "containsField": ensure_list(contains_field),
231
+ "containsText": ensure_list(contains_text),
232
+ "myRole": ensure_list(my_role),
81
233
  }
82
234
 
83
235
  return AlbertPaginator(
@@ -94,32 +246,169 @@ class CustomTemplatesCollection(BaseCollection):
94
246
  def get_all(
95
247
  self,
96
248
  *,
97
- text: str | None = None,
249
+ name: str | list[str] | None = None,
250
+ created_by: str | None = None,
251
+ category: TemplateCategory | None = None,
252
+ start_key: str | None = None,
98
253
  max_items: int | None = None,
99
- offset: int | None = 0,
100
254
  ) -> Iterator[CustomTemplate]:
101
- """
102
- Retrieve fully hydrated CustomTemplate entities with optional filters.
103
-
104
- This method returns complete entity data using `get_by_id`.
105
- Use :meth:`search` for faster retrieval when you only need lightweight, partial (unhydrated) entities.
255
+ """Iterate over CustomTemplate entities with optional filters.
106
256
 
107
257
  Parameters
108
258
  ----------
109
- text : str, optional
110
- Text filter for template name or content.
259
+ name : str | list[str], optional
260
+ Filter by template name(s).
261
+ created_by : str, optional
262
+ Filter by creator id.
263
+ category : TemplateCategory, optional
264
+ Filter by category.
265
+ start_key : str, optional
266
+ Provide the `lastKey` from a previous request to resume pagination.
111
267
  max_items : int, optional
112
- Maximum number of items to return in total. If None, fetches all available items.
113
- offset : int, optional
114
- Offset for search pagination.
268
+ Maximum number of items to return.
115
269
 
116
270
  Returns
117
271
  -------
118
272
  Iterator[CustomTemplate]
119
- An iterator of CustomTemplate entities.
273
+ An iterator of CustomTemplates.
120
274
  """
121
- for item in self.search(text=text, max_items=max_items, offset=offset):
122
- try:
123
- yield self.get_by_id(id=item.id)
124
- except AlbertHTTPError as e:
125
- logger.warning(f"Error hydrating custom template {item.id}: {e}")
275
+ params = {
276
+ "startKey": start_key,
277
+ "createdBy": created_by,
278
+ "category": category,
279
+ }
280
+ params["name"] = ensure_list(name)
281
+
282
+ return AlbertPaginator(
283
+ mode=PaginationMode.KEY,
284
+ path=self.base_path,
285
+ session=self.session,
286
+ params=params,
287
+ max_items=max_items,
288
+ deserialize=lambda items: [CustomTemplate(**item) for item in items],
289
+ )
290
+
291
+ @validate_call
292
+ def delete(self, *, id: CustomTemplateId) -> None:
293
+ """
294
+ Delete a custom template by id.
295
+
296
+ Parameters
297
+ ----------
298
+ id : CustomTemplateId
299
+ The id of the custom template to delete.
300
+
301
+ Returns
302
+ -------
303
+ None
304
+ """
305
+
306
+ url = f"{self.base_path}/{id}"
307
+ self.session.delete(url)
308
+
309
+ @validate_call
310
+ def update_acl(
311
+ self,
312
+ *,
313
+ custom_template_id: CustomTemplateId,
314
+ acl_class: str | None = None,
315
+ acls: list[ACL] | None = None,
316
+ ) -> CustomTemplate:
317
+ """
318
+ Replace a template's ACL class and/or entries with the provided values.
319
+
320
+ Parameters
321
+ ----------
322
+ custom_template_id : CustomTemplateId
323
+ The id of the custom template to update.
324
+ acl_class : str | None, optional
325
+ The ACL class to set (if provided).
326
+ acls : list[ACL] | None, optional
327
+ The ACL entries to replace on the template.
328
+
329
+ Returns
330
+ -------
331
+ CustomTemplate
332
+ The updated CustomTemplate.
333
+ """
334
+
335
+ if acl_class is None and acls is None:
336
+ raise ValueError("Provide an ACL class and/or ACL entries to update.")
337
+
338
+ data = []
339
+ current_template: CustomTemplate | None = None
340
+
341
+ if acl_class is not None:
342
+ acl_class_value = getattr(acl_class, "value", acl_class)
343
+ data.append(
344
+ {
345
+ "operation": PatchOperation.UPDATE.value,
346
+ "attribute": "class",
347
+ "newValue": acl_class_value,
348
+ }
349
+ )
350
+
351
+ if acls is not None:
352
+ current_template = self.get_by_id(id=custom_template_id)
353
+ current_acl = (
354
+ current_template.acl.fgclist
355
+ if current_template.acl and current_template.acl.fgclist
356
+ else []
357
+ )
358
+ current_entries = {
359
+ entry.id: getattr(entry.fgc, "value", entry.fgc) for entry in current_acl
360
+ }
361
+
362
+ desired_entries = {entry.id: getattr(entry.fgc, "value", entry.fgc) for entry in acls}
363
+
364
+ to_add = []
365
+ to_delete = []
366
+ to_update = []
367
+
368
+ for entry_id, new_fgc in desired_entries.items():
369
+ if entry_id not in current_entries:
370
+ payload = {"id": entry_id}
371
+ if new_fgc is not None:
372
+ payload["fgc"] = new_fgc
373
+ to_add.append(payload)
374
+ else:
375
+ old_fgc = current_entries[entry_id]
376
+ if new_fgc is not None and old_fgc != new_fgc:
377
+ to_update.append(
378
+ {
379
+ "operation": PatchOperation.UPDATE.value,
380
+ "attribute": "fgc",
381
+ "id": entry_id,
382
+ "oldValue": old_fgc,
383
+ "newValue": new_fgc,
384
+ }
385
+ )
386
+
387
+ for entry_id in current_entries:
388
+ if entry_id not in desired_entries:
389
+ to_delete.append({"id": entry_id})
390
+
391
+ if to_add:
392
+ data.append(
393
+ {
394
+ "operation": PatchOperation.ADD.value,
395
+ "attribute": "ACL",
396
+ "newValue": to_add,
397
+ }
398
+ )
399
+ if to_delete:
400
+ data.append(
401
+ {
402
+ "operation": PatchOperation.DELETE.value,
403
+ "attribute": "ACL",
404
+ "oldValue": to_delete,
405
+ }
406
+ )
407
+ data.extend(to_update)
408
+
409
+ if not data:
410
+ return current_template or self.get_by_id(id=custom_template_id)
411
+
412
+ url = f"{self.base_path}/{custom_template_id}/acl"
413
+ self.session.patch(url, json={"data": data})
414
+ return self.get_by_id(id=custom_template_id)
@@ -7,6 +7,7 @@ from albert.core.pagination import AlbertPaginator
7
7
  from albert.core.session import AlbertSession
8
8
  from albert.core.shared.enums import OrderBy, PaginationMode
9
9
  from albert.core.shared.identifiers import DataColumnId
10
+ from albert.core.utils import ensure_list
10
11
  from albert.resources.data_columns import DataColumn
11
12
 
12
13
 
@@ -102,12 +103,12 @@ class DataColumnCollection(BaseCollection):
102
103
  yield from (DataColumn(**item) for item in items)
103
104
 
104
105
  params = {
105
- "orderBy": order_by.value,
106
+ "orderBy": order_by,
106
107
  "startKey": start_key,
107
- "name": [name] if isinstance(name, str) else name,
108
+ "name": ensure_list(name),
108
109
  "exactMatch": exact_match,
109
110
  "default": default,
110
- "dataColumns": [ids] if isinstance(ids, str) else ids,
111
+ "dataColumns": ensure_list(ids),
111
112
  }
112
113
 
113
114
  return AlbertPaginator(
@@ -285,7 +285,7 @@ class DataTemplateCollection(BaseCollection):
285
285
  """
286
286
  params = {
287
287
  "offset": offset,
288
- "order": order_by.value,
288
+ "order": order_by,
289
289
  "text": name,
290
290
  "userId": user_id,
291
291
  }
@@ -282,10 +282,10 @@ class EntityTypeCollection(BaseCollection):
282
282
  Returns an iterator of EntityType items matching the search criteria.
283
283
  """
284
284
  params = {
285
- "service": service.value if service else None,
285
+ "service": service,
286
286
  "limit": max_items,
287
287
  "startKey": start_key,
288
- "orderBy": order.value if order else None,
288
+ "orderBy": order,
289
289
  }
290
290
  return AlbertPaginator(
291
291
  mode=PaginationMode.KEY,
@@ -16,6 +16,7 @@ from albert.core.shared.identifiers import (
16
16
  SearchProjectId,
17
17
  WorksheetId,
18
18
  )
19
+ from albert.core.utils import ensure_list
19
20
  from albert.resources.facet import FacetItem
20
21
  from albert.resources.inventory import (
21
22
  ALL_MERGE_MODULES,
@@ -88,10 +89,10 @@ class InventoryCollection(BaseCollection):
88
89
  # define merge endpoint
89
90
  url = f"{self.base_path}/merge"
90
91
 
91
- if isinstance(child_id, list):
92
- child_inventories = [{"id": i} for i in child_id]
93
- else:
94
- child_inventories = [{"id": child_id}]
92
+ child_ids = ensure_list(child_id) or []
93
+ if not child_ids:
94
+ raise ValueError("At least one child inventory id is required for merge operations.")
95
+ child_inventories = [{"id": i} for i in child_ids]
95
96
 
96
97
  # define payload using the class
97
98
  payload = MergeInventory(
@@ -360,9 +361,9 @@ class InventoryCollection(BaseCollection):
360
361
 
361
362
  params = {
362
363
  "text": text,
363
- "order": order.value if order is not None else None,
364
+ "order": order,
364
365
  "sortBy": sort_by if sort_by is not None else None,
365
- "category": [c.value for c in category] if category is not None else None,
366
+ "category": category,
366
367
  "tags": tags,
367
368
  "manufacturer": [c.name for c in company] if company is not None else None,
368
369
  "cas": [c.number for c in cas] if cas is not None else None,
@@ -447,8 +448,7 @@ class InventoryCollection(BaseCollection):
447
448
  This can be used for example to fetch all remaining tags as part of an iterative
448
449
  refinement of a search.
449
450
  """
450
- if isinstance(name, str):
451
- name = [name]
451
+ name = ensure_list(name) or []
452
452
 
453
453
  facets = self.get_all_facets(
454
454
  text=text,
@@ -92,7 +92,7 @@ class ListsCollection(BaseCollection):
92
92
  params = {
93
93
  "startKey": start_key,
94
94
  "name": names,
95
- "category": category.value if isinstance(category, ListItemCategory) else category,
95
+ "category": category,
96
96
  "listType": list_type,
97
97
  "orderBy": order_by,
98
98
  }
@@ -4,6 +4,7 @@ from albert.collections.base import BaseCollection
4
4
  from albert.core.pagination import AlbertPaginator
5
5
  from albert.core.session import AlbertSession
6
6
  from albert.core.shared.enums import PaginationMode
7
+ from albert.core.utils import ensure_list
7
8
  from albert.resources.locations import Location
8
9
 
9
10
 
@@ -64,9 +65,8 @@ class LocationCollection(BaseCollection):
64
65
  }
65
66
  if ids:
66
67
  params["id"] = ids
67
- if name:
68
- params["name"] = [name] if isinstance(name, str) else name
69
- params["exactMatch"] = exact_match
68
+ params["name"] = ensure_list(name)
69
+ params["exactMatch"] = exact_match
70
70
 
71
71
  return AlbertPaginator(
72
72
  mode=PaginationMode.KEY,