kalbio 0.2.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.
kalbio/records.py ADDED
@@ -0,0 +1,1173 @@
1
+ """Records module for managing Kaleidoscope record operations.
2
+
3
+ This module provides classes and services for interacting with records in the Kaleidoscope system.
4
+ It includes functionality for filtering, sorting, managing record values, handling file attachments,
5
+ and searching records.
6
+
7
+ Classes:
8
+ FilterRuleTypeEnum: Enumeration of available filter rule types for record filtering
9
+ ViewFieldFilter: TypedDict for view-based field filter configuration
10
+ ViewFieldSort: TypedDict for view-based field sort configuration
11
+ FieldFilter: TypedDict for entity-based field filter configuration
12
+ FieldSort: TypedDict for entity-based field sort configuration
13
+ RecordValue: Model representing a single value within a record field
14
+ Record: Model representing a complete record with all its fields and values
15
+ RecordsService: Service class providing record-related API operations
16
+
17
+ The module uses Pydantic models for data validation and serialization, and integrates
18
+ with the KaleidoscopeClient for API communication.
19
+
20
+ Example:
21
+ ```python
22
+ # Get a record by ID
23
+ record = client.records.get_record_by_id("record_uuid")
24
+
25
+ # Add a value to a record field
26
+ record.add_value(
27
+ field_id="field_uuid",
28
+ content="Experiment result",
29
+ activity_id="activity_uuid"
30
+ )
31
+
32
+ # Get a field value
33
+ value = record.get_value_content(field_id="field_uuid")
34
+
35
+ # Update a field
36
+ record.update_field(
37
+ field_id="field_uuid",
38
+ value="Updated value",
39
+ activity_id="activity_uuid"
40
+ )
41
+
42
+ # Get activities associated with a record
43
+ activities = record.get_activities()
44
+ ```
45
+ """
46
+
47
+ from __future__ import annotations
48
+ import itertools
49
+ from cachetools import TTLCache
50
+ import logging
51
+ from datetime import datetime
52
+ from enum import Enum
53
+ import json
54
+ from kalbio._kaleidoscope_model import _KaleidoscopeBaseModel
55
+ from kalbio.client import KaleidoscopeClient
56
+ from kalbio.entity_fields import EntityFieldIdentifier
57
+ from pydantic import TypeAdapter, ValidationError
58
+ from typing import (
59
+ TYPE_CHECKING,
60
+ Any,
61
+ BinaryIO,
62
+ Dict,
63
+ List,
64
+ Optional,
65
+ TypedDict,
66
+ Union,
67
+ Unpack,
68
+ )
69
+
70
+ if TYPE_CHECKING:
71
+ from kalbio.activities import Activity, ActivityIdentifier
72
+
73
+ _logger = logging.getLogger(__name__)
74
+
75
+
76
+ class FilterRuleTypeEnum(str, Enum):
77
+ """Enumeration of filter rule types for record filtering operations.
78
+
79
+ This enum defines all available filter rule types that can be applied to record properties.
80
+ Filter rules are categorized into several groups:
81
+
82
+ - **Existence checks**: `IS_SET`, `IS_EMPTY`
83
+ - **Equality checks**: `IS_EQUAL`, `IS_NOT_EQUAL`, `IS_ANY_OF_TEXT`
84
+ - **String operations**: `INCLUDES`, `DOES_NOT_INCLUDE`, `STARTS_WITH`, `ENDS_WITH`
85
+ - **Membership checks**: `IS_IN`, `IS_NOT_IN`
86
+ - **Set operations**: `VALUE_IS_SUBSET_OF_PROPS`, `VALUE_IS_SUPERSET_OF_PROPS`,
87
+ `VALUE_HAS_OVERLAP_WITH_PROPS`, `VALUE_HAS_NO_OVERLAP_WITH_PROPS`,
88
+ `VALUE_HAS_SAME_ELEMENTS_AS_PROPS`
89
+ - **Numeric comparisons**: `IS_LESS_THAN`, `IS_LESS_THAN_EQUAL`, `IS_GREATER_THAN`,
90
+ `IS_GREATER_THAN_EQUAL`
91
+ - **Absolute date comparisons**: `IS_BEFORE`, `IS_AFTER`, `IS_BETWEEN`
92
+ - **Relative date comparisons**:
93
+ - Day-based: `IS_BEFORE_RELATIVE_DAY`, `IS_AFTER_RELATIVE_DAY`, `IS_BETWEEN_RELATIVE_DAY`
94
+ - Week-based: `IS_BEFORE_RELATIVE_WEEK`, `IS_AFTER_RELATIVE_WEEK`, `IS_BETWEEN_RELATIVE_WEEK`,
95
+ `IS_LAST_WEEK`, `IS_THIS_WEEK`, `IS_NEXT_WEEK`
96
+ - Month-based: `IS_BEFORE_RELATIVE_MONTH`, `IS_AFTER_RELATIVE_MONTH`, `IS_BETWEEN_RELATIVE_MONTH`,
97
+ `IS_THIS_MONTH`, `IS_NEXT_MONTH`
98
+ - **Update tracking**: `IS_LAST_UPDATED_AFTER`
99
+
100
+ Each enum value corresponds to a string representation used in filter configurations.
101
+ """
102
+
103
+ IS_SET = "is_set"
104
+ IS_EMPTY = "is_empty"
105
+ IS_EQUAL = "is_equal"
106
+ IS_ANY_OF_TEXT = "is_any_of_text"
107
+ IS_NOT_EQUAL = "is_not_equal"
108
+ INCLUDES = "includes"
109
+ DOES_NOT_INCLUDE = "does_not_include"
110
+ IS_IN = "is_in"
111
+ IS_NOT_IN = "is_not_in"
112
+ VALUE_IS_SUBSET_OF_PROPS = "value_is_subset_of_props"
113
+ VALUE_IS_SUPERSET_OF_PROPS = "value_is_superset_of_props"
114
+ VALUE_HAS_OVERLAP_WITH_PROPS = "value_has_overlap_with_props"
115
+ VALUE_HAS_NO_OVERLAP_WITH_PROPS = "value_has_no_overlap_with_props"
116
+ VALUE_HAS_SAME_ELEMENTS_AS_PROPS = "value_has_same_elements_as_props"
117
+ STARTS_WITH = "starts_with"
118
+ ENDS_WITH = "ends_with"
119
+ IS_LESS_THAN = "is_less_than"
120
+ IS_LESS_THAN_EQUAL = "is_less_than_equal"
121
+ IS_GREATER_THAN = "is_greater_than"
122
+ IS_GREATER_THAN_EQUAL = "is_greater_than_equal"
123
+ IS_BEFORE = "is_before"
124
+ IS_AFTER = "is_after"
125
+ IS_BETWEEN = "is_between"
126
+ IS_BEFORE_RELATIVE_DAY = "is_before_relative_day"
127
+ IS_AFTER_RELATIVE_DAY = "is_after_relative_day"
128
+ IS_BETWEEN_RELATIVE_DAY = "is_between_relative_day"
129
+ IS_BEFORE_RELATIVE_WEEK = "is_before_relative_week"
130
+ IS_AFTER_RELATIVE_WEEK = "is_after_relative_week"
131
+ IS_BETWEEN_RELATIVE_WEEK = "is_between_relative_week"
132
+ IS_BEFORE_RELATIVE_MONTH = "is_before_relative_month"
133
+ IS_AFTER_RELATIVE_MONTH = "is_after_relative_month"
134
+ IS_BETWEEN_RELATIVE_MONTH = "is_between_relative_month"
135
+ IS_LAST_WEEK = "is_last_week"
136
+ IS_THIS_WEEK = "is_this_week"
137
+ IS_NEXT_WEEK = "is_next_week"
138
+ IS_THIS_MONTH = "is_this_month"
139
+ IS_NEXT_MONTH = "is_next_month"
140
+ IS_LAST_UPDATED_AFTER = "is_last_updated_after"
141
+
142
+
143
+ class ViewFieldFilter(TypedDict):
144
+ """TypedDict for view-based field filter configuration.
145
+
146
+ Attributes:
147
+ key_field_id (Optional[str]): The ID of the key field to filter by.
148
+ view_field_id (Optional[str]): The ID of the view field to filter by.
149
+ filter_type (FilterRuleTypeEnum): The type of filter rule to apply.
150
+ filter_prop (Any): The property value to filter against.
151
+
152
+ Example:
153
+ ```python
154
+ from kalbio.records import FilterRuleTypeEnum, ViewFieldFilter
155
+
156
+ filter_config: ViewFieldFilter = {
157
+ "key_field_id": "field_uuid",
158
+ "view_field_id": None,
159
+ "filter_type": FilterRuleTypeEnum.IS_EQUAL,
160
+ "filter_prop": "S",
161
+ }
162
+ ```
163
+ """
164
+
165
+ key_field_id: Optional[str]
166
+ view_field_id: Optional[str]
167
+ filter_type: FilterRuleTypeEnum
168
+ filter_prop: Any
169
+
170
+
171
+ class ViewFieldSort(TypedDict):
172
+ """TypedDict for view-based field sort configuration.
173
+
174
+ Attributes:
175
+ key_field_id (Optional[str]): The ID of the key field to sort by.
176
+ view_field_id (Optional[str]): The ID of the view field to sort by.
177
+ descending (bool): Whether to sort in descending order.
178
+
179
+ Example:
180
+ ```python
181
+ from kalbio.records import ViewFieldSort
182
+
183
+ sort_config: ViewFieldSort = {
184
+ "key_field_id": "field_uuid",
185
+ "view_field_id": None,
186
+ "descending": True,
187
+ }
188
+ ```
189
+ """
190
+
191
+ key_field_id: Optional[str]
192
+ view_field_id: Optional[str]
193
+ descending: bool
194
+
195
+
196
+ class FieldFilter(TypedDict):
197
+ """TypedDict for entity-based field filter configuration.
198
+
199
+ Attributes:
200
+ field_id (Optional[str]): The ID of the field to filter by.
201
+ filter_type (FilterRuleTypeEnum): The type of filter rule to apply.
202
+ filter_prop (Any): The property value to filter against.
203
+
204
+ Example:
205
+ ```python
206
+ from kalbio.records import FieldFilter, FilterRuleTypeEnum
207
+
208
+ field_filter: FieldFilter = {
209
+ "field_id": "field_uuid",
210
+ "filter_type": FilterRuleTypeEnum.STARTS_WITH,
211
+ "filter_prop": "EXP-",
212
+ }
213
+ ```
214
+ """
215
+
216
+ field_id: Optional[str]
217
+ filter_type: FilterRuleTypeEnum
218
+ filter_prop: Any
219
+
220
+
221
+ class FieldSort(TypedDict):
222
+ """TypedDict for entity-based field sort configuration.
223
+
224
+ Attributes:
225
+ field_id (Optional[str]): The ID of the field to sort by.
226
+ descending (bool): Whether to sort in descending order.
227
+
228
+ Example:
229
+ ```python
230
+ from kalbio.records import FieldSort
231
+
232
+ sort_config: FieldSort = {
233
+ "field_id": "field_uuid",
234
+ "descending": False,
235
+ }
236
+ ```
237
+ """
238
+
239
+ field_id: Optional[str]
240
+ descending: bool
241
+
242
+
243
+ class RecordValue(_KaleidoscopeBaseModel):
244
+ """Represents a single value entry in a record within the Kaleidoscope system.
245
+
246
+ A RecordValue stores the actual content of a record along with metadata about when it was
247
+ created and its relationships to parent records and operations.
248
+
249
+ Attributes:
250
+ id (str): UUID of the record value
251
+ content (Any): The actual data value stored in this record. Can be of any type.
252
+ created_at (Optional[datetime]): Timestamp indicating when this value was created.
253
+ Defaults to None.
254
+ record_id (Optional[str]): Identifier of the parent record this value belongs to.
255
+ Defaults to None.
256
+ operation_id (Optional[str]): Identifier of the operation that created or modified
257
+ this value. Defaults to None.
258
+
259
+ Example:
260
+ ```python
261
+ from datetime import datetime
262
+ from kalbio.records import RecordValue
263
+
264
+ value = RecordValue(
265
+ id="value_uuid",
266
+ content="Completed",
267
+ created_at=datetime.utcnow(),
268
+ record_id="record_uuid",
269
+ operation_id="activity_uuid",
270
+ )
271
+ ```
272
+ """
273
+
274
+ content: Any
275
+ created_at: Optional[datetime] = None # data value
276
+ record_id: Optional[str] = None # data value
277
+ operation_id: Optional[str] = None # data value
278
+
279
+ def __str__(self):
280
+ return f"{self.content}"
281
+
282
+
283
+ class Record(_KaleidoscopeBaseModel):
284
+ """Represents a record in the Kaleidoscope system.
285
+
286
+ A Record is a core data structure that contains values organized by fields, can be associated
287
+ with experiments, and may have sub-records. Records are identified by a unique ID and belong
288
+ to an entity slice.
289
+
290
+ Attributes:
291
+ id (str): UUID of the record.
292
+ created_at (datetime): The timestamp when the record was created.
293
+ entity_slice_id (str): The ID of the entity slice this record belongs to.
294
+ identifier_ids (List[str]): A list of identifier IDs associated with this record.
295
+ record_identifier (str): Human-readable identifier string for the record.
296
+ record_values (Dict[str, List[RecordValue]]): A dictionary mapping field IDs to lists of record values.
297
+ initial_operation_id (Optional[str]): The ID of the initial operation that created this record, if applicable.
298
+ sub_record_ids (List[str]): A list of IDs for sub-records associated with this record.
299
+
300
+ Example:
301
+ ```python
302
+ from kalbio.client import KaleidoscopeClient
303
+
304
+ client = KaleidoscopeClient()
305
+ record = client.records.get_record_by_id("record_uuid")
306
+ latest_value = record.get_value_content(field_id="field_uuid")
307
+ print(record.record_identifier, latest_value)
308
+ ```
309
+ """
310
+
311
+ created_at: datetime
312
+ entity_slice_id: str
313
+ identifier_ids: List[str]
314
+ record_identifier: str
315
+ record_values: Dict[str, List[RecordValue]] # [field_id, values[]]
316
+ initial_operation_id: Optional[str] = None
317
+ sub_record_ids: List[str]
318
+
319
+ def __str__(self):
320
+ return f"{self.record_identifier}"
321
+
322
+ def get_activities(self) -> List["Activity"]:
323
+ """Retrieves a list of activities associated with this record.
324
+
325
+ Returns:
326
+ A list of activities related to this record.
327
+
328
+ Note:
329
+ If an exception occurs during the API request, it logs the error and returns an empty list.
330
+
331
+ Example:
332
+ ```python
333
+ activities = record.get_activities()
334
+ for activity in activities:
335
+ print(activity.id)
336
+ ```
337
+ """
338
+ return self._client.activities.get_activities_with_record(self.id)
339
+
340
+ def add_value(
341
+ self,
342
+ field_id: EntityFieldIdentifier,
343
+ content: Any,
344
+ activity_id: Optional[ActivityIdentifier] = None,
345
+ ) -> None:
346
+ """Adds a value to a specified field for a given activity.
347
+
348
+ Args:
349
+ field_id: Identifier of the field to which the value will be added.
350
+
351
+ Any type of EntityFieldIdentifier will be accepted and resolved.
352
+ content: The value/content to be saved for the field.
353
+ activity_id: The identifier of the activity. Defaults to None.
354
+
355
+ Any type of EntityFieldIdentifier will be accepted and resolved.
356
+
357
+ Example:
358
+ ```python
359
+ record.add_value(
360
+ field_id="field_uuid",
361
+ content="Experiment result",
362
+ activity_id="activity_uuid",
363
+ )
364
+ ```
365
+ """
366
+ try:
367
+ self._client._post(
368
+ "/records/" + self.id + "/values",
369
+ {
370
+ "content": content,
371
+ "field_id": self._client.entity_fields._resolve_data_field_id(
372
+ field_id
373
+ ),
374
+ "operation_id": (
375
+ self._client.activities._resolve_activity_id(activity_id)
376
+ ),
377
+ },
378
+ )
379
+ self.refetch()
380
+ return
381
+ except Exception as e:
382
+ _logger.error(f"Error adding this value: {e}")
383
+ return
384
+
385
+ def get_value_content(
386
+ self,
387
+ field_id: EntityFieldIdentifier,
388
+ activity_id: Optional[ActivityIdentifier] = None,
389
+ include_sub_record_values: Optional[bool] = False,
390
+ sub_record_id: Optional["RecordIdentifier"] = None,
391
+ ) -> Any | None:
392
+ """Retrieves the content of a record value for a specified field.
393
+
394
+ Optionally filtered by activity, sub-record, and inclusion of sub-record values.
395
+
396
+ Args:
397
+ field_id: The ID of the field to retrieve the value for.
398
+ activity_id: The ID of the activity to filter values by. Defaults to None.
399
+ include_sub_record_values: Whether to include values from sub-records. Defaults to False.
400
+ sub_record_id: The ID of a specific sub-record to filter values by. Defaults to None.
401
+
402
+ Returns:
403
+ The content of the most recent matching record value, or None if no value is found.
404
+
405
+ Example:
406
+ ```python
407
+ latest_content = record.get_value_content(
408
+ field_id="field_uuid",
409
+ activity_id="activity_uuid",
410
+ include_sub_record_values=True,
411
+ )
412
+ print(latest_content)
413
+ ```
414
+ """
415
+ field_uuid = self._client.entity_fields._resolve_data_field_id(field_id)
416
+ activity_uuid = self._client.activities._resolve_activity_id(activity_id)
417
+ sub_record_uuid = self._client.records._resolve_to_record_id(sub_record_id)
418
+
419
+ if not field_uuid:
420
+ return None
421
+
422
+ values = self.record_values.get(field_uuid)
423
+ if not values:
424
+ return None
425
+
426
+ # include key values in the activity data (record_id = None)
427
+ if activity_uuid is not None:
428
+ values = [
429
+ value
430
+ for value in values
431
+ if (value.operation_id == activity_uuid) or value.record_id is None
432
+ ]
433
+
434
+ if not include_sub_record_values:
435
+ # key values have None for the record_id
436
+ values = [
437
+ value
438
+ for value in values
439
+ if value.record_id == self.id or value.record_id is None
440
+ ]
441
+
442
+ if sub_record_uuid:
443
+ values = [value for value in values if value.record_id == sub_record_uuid]
444
+
445
+ sorted_values: List[RecordValue] = sorted(
446
+ values,
447
+ key=lambda x: x.created_at if x.created_at else datetime.min,
448
+ reverse=True,
449
+ )
450
+ value = next(iter(sorted_values), None)
451
+ return value.content if value else None
452
+
453
+ def get_activity_data(self, activity_id: ActivityIdentifier) -> dict:
454
+ """Retrieves activity data for a specific activity ID.
455
+
456
+ Args:
457
+ activity_id: The identifier of the activity.
458
+
459
+ Any type of ActivityIdentifier will be accepted and resolved.
460
+
461
+ Returns:
462
+ A dictionary mapping field IDs to their corresponding values for the given activity.
463
+ Only fields with non-None values are included.
464
+
465
+ Example:
466
+ ```python
467
+ activity_data = record.get_activity_data(activity_id="activity_uuid")
468
+ print(activity_data.get("field_uuid"))
469
+ ```
470
+ """
471
+ activity_uuid = self._client.activities._resolve_activity_id(activity_id)
472
+
473
+ data = {}
474
+ for field_id in self.record_values.keys():
475
+ result = self.get_value_content(field_id, activity_uuid)
476
+ if result is not None:
477
+ data[field_id] = result
478
+
479
+ return data
480
+
481
+ def update_field(
482
+ self,
483
+ field_id: EntityFieldIdentifier,
484
+ value: Any,
485
+ activity_id: ActivityIdentifier | None,
486
+ ) -> RecordValue | None:
487
+ """Updates a specific field of the record with the given value.
488
+
489
+ Args:
490
+ field_id: The ID of the field to update.
491
+ value: The new value to set for the field.
492
+ activity_id: The ID of the activity associated with the update, or None if not an activity value
493
+
494
+ Returns:
495
+ The updated record value if the operation is successful, otherwise None.
496
+
497
+ Example:
498
+ ```python
499
+ updated_value = record.update_field(
500
+ field_id="field_uuid",
501
+ value="Updated value",
502
+ activity_id="activity_uuid",
503
+ )
504
+ print(updated_value.content if updated_value else None)
505
+ ```
506
+ """
507
+ try:
508
+ field_uuid = self._client.entity_fields._resolve_data_field_id(field_id)
509
+ activity_uuid = self._client.activities._resolve_activity_id(activity_id)
510
+
511
+ body = {
512
+ "field_id": field_uuid,
513
+ "content": value,
514
+ "operation_id": activity_uuid,
515
+ }
516
+
517
+ resp = self._client._post("/records/" + self.id + "/values", body)
518
+ self.refetch()
519
+
520
+ if resp is None or len(resp) == 0:
521
+ return None
522
+
523
+ return RecordValue.model_validate(resp.get("resource"))
524
+ except Exception as e:
525
+ _logger.error(f"Error updating the field: {e}")
526
+ return None
527
+
528
+ def update_field_file(
529
+ self,
530
+ field_id: EntityFieldIdentifier,
531
+ file_name: str,
532
+ file_data: BinaryIO,
533
+ file_type: str,
534
+ activity_id: Optional[ActivityIdentifier] = None,
535
+ ) -> RecordValue | None:
536
+ """Update a record value with a file.
537
+
538
+ Args:
539
+ field_id: The ID of the field to update.
540
+ file_name: The name of the file to upload.
541
+ file_data: The binary data of the file.
542
+ file_type: The MIME type of the file.
543
+ activity_id: The ID of the activity, if applicable. Defaults to None.
544
+
545
+ Returns:
546
+ The updated record value if the operation is successful, otherwise None.
547
+
548
+ Example:
549
+ ```python
550
+ with open("report.pdf", "rb") as file_data:
551
+ uploaded_value = record.update_field_file(
552
+ field_id="file_field_uuid",
553
+ file_name="report.pdf",
554
+ file_data=file_data,
555
+ file_type="application/pdf",
556
+ )
557
+ ```
558
+ """
559
+ try:
560
+ field_uuid = self._client.entity_fields._resolve_data_field_id(field_id)
561
+ activity_uuid = self._client.activities._resolve_activity_id(activity_id)
562
+
563
+ body = {
564
+ "field_id": field_uuid,
565
+ }
566
+
567
+ if activity_uuid:
568
+ body["operation_id"] = activity_uuid
569
+
570
+ resp = self._client._post_file(
571
+ "/records/" + self.id + "/values/file",
572
+ (file_name, file_data, file_type),
573
+ body,
574
+ )
575
+ self.refetch()
576
+
577
+ if resp is None or len(resp) == 0:
578
+ return None
579
+
580
+ return RecordValue.model_validate(resp.get("resource"))
581
+ except Exception as e:
582
+ _logger.error(f"Error uploading file to field: {e}")
583
+ return None
584
+
585
+ def get_values(self) -> List[RecordValue]:
586
+ """Retrieve all values associated with this record.
587
+
588
+ Makes a GET request to fetch the values for the current record using its ID.
589
+ If the request is successful, returns the list of record values. If the response
590
+ is None or an error occurs during the request, returns an empty list.
591
+
592
+ Returns:
593
+ A list of RecordValue objects associated with this record. Returns an empty list if no values exist.
594
+
595
+ Note:
596
+ If an exception occurs during the API request, it logs the error and returns an empty list.
597
+
598
+ Example:
599
+ ```python
600
+ values = record.get_values()
601
+ print([value.content for value in values])
602
+ ```
603
+ """
604
+ try:
605
+ resp = self._client._get("/records/" + self.id + "/values")
606
+ if resp is None:
607
+ return []
608
+ return TypeAdapter(List[RecordValue]).validate_python(resp)
609
+ except Exception as e:
610
+ _logger.error(f"Error fetching values for this record: {e}")
611
+ return []
612
+
613
+ def refetch(self):
614
+ """Refreshes all the data of the current record instance.
615
+
616
+ The record is also removed from all local caches of its associated client.
617
+
618
+ Automatically called by mutating methods of this record, but can also be called manually.
619
+
620
+ Example:
621
+ ```python
622
+ record.refetch()
623
+ refreshed_value = record.get_value_content(field_id="field_uuid")
624
+ ```
625
+ """
626
+
627
+ self._client.records._clear_record_from_caches(self)
628
+
629
+ new = self._client.records.get_record_by_id(self.id)
630
+ for k, v in new.__dict__.items():
631
+ setattr(self, k, v)
632
+
633
+
634
+ type RecordIdentifier = Union[Record, str, dict[EntityFieldIdentifier, str]]
635
+ """Identifier type for Record
636
+
637
+ Record can be identified by:
638
+
639
+ * object instance of a Record
640
+ * uuid
641
+ * key field dictionary
642
+ * a dict that maps `EntityFieldIdentifier`s to `str`s
643
+ """
644
+
645
+
646
+ class SearchRecordsQuery(TypedDict):
647
+ """TypedDict for search records query parameters.
648
+
649
+ Attributes:
650
+ record_set_id (Optional[str]): The ID of the record set to search within.
651
+ program_id (Optional[str]): The ID of the program associated with the records.
652
+ entity_slice_id (Optional[str]): The ID of the entity slice to filter records.
653
+ operation_id (Optional[str]): The ID of the operation to filter records.
654
+ identifier_ids (Optional[List[str]]): List of identifier IDs to filter records.
655
+ record_set_filters (Optional[List[str]]): List of filters to apply on record sets.
656
+ view_field_filters (Optional[List[ViewFieldFilter]]): List of filters to apply on view fields.
657
+ view_field_sorts (Optional[List[ViewFieldSort]]): List of sorting criteria for view fields.
658
+ entity_field_filters (Optional[List[FieldFilter]]): List of filters to apply on entity fields.
659
+ entity_field_sorts (Optional[List[FieldSort]]): List of sorting criteria for entity fields.
660
+ search_text (Optional[str]): Text string to search for within records.
661
+ limit (Optional[int]): Maximum number of records to return in the search results.
662
+
663
+ Example:
664
+ ```python
665
+ from kalbio.records import SearchRecordsQuery, FilterRuleTypeEnum
666
+
667
+ query: SearchRecordsQuery = {
668
+ "entity_slice_id": "entity_uuid",
669
+ "search_text": "treatment",
670
+ "entity_field_filters": [
671
+ {
672
+ "field_id": "status_field_uuid",
673
+ "filter_type": FilterRuleTypeEnum.IS_EQUAL,
674
+ "filter_prop": "Completed",
675
+ }
676
+ ],
677
+ "limit": 25,
678
+ }
679
+ ```
680
+ """
681
+
682
+ record_set_id: Optional[str]
683
+ program_id: Optional[str]
684
+ entity_slice_id: Optional[str]
685
+ operation_id: Optional[str]
686
+ identifier_ids: Optional[List[str]]
687
+ record_set_filters: Optional[List[str]]
688
+ view_field_filters: Optional[List[ViewFieldFilter]]
689
+ view_field_sorts: Optional[List[ViewFieldSort]]
690
+ entity_field_filters: Optional[List[FieldFilter]]
691
+ entity_field_sorts: Optional[List[FieldSort]]
692
+ search_text: Optional[str]
693
+ limit: Optional[int]
694
+
695
+
696
+ class RecordsService:
697
+ """Service class for managing records in Kaleidoscope.
698
+
699
+ This service provides methods for creating, retrieving, and searching records,
700
+ as well as managing record values and file uploads. It acts as an interface
701
+ between the KaleidoscopeClient and Record objects.
702
+
703
+ Example:
704
+ ```python
705
+ # Get a record by ID
706
+ record = client.records.get_record_by_id("record_uuid")
707
+ # Get multiple records (preserves order)
708
+ records = client.records.get_records_by_ids(["id1", "id2"])
709
+ # Search by text
710
+ matches = client.records.search_records(search_text="experiment-a")
711
+ ```
712
+ """
713
+
714
+ # fmt: off
715
+ _records_uuid_map: TTLCache[str, Record | None] = TTLCache(
716
+ maxsize=1000, ttl=60
717
+ )
718
+ _records_key_field_map: TTLCache[frozenset, Record | None] = TTLCache(
719
+ maxsize=1000, ttl=60
720
+ )
721
+ # fmt: on
722
+
723
+ def __init__(self, client: KaleidoscopeClient):
724
+ self._client = client
725
+
726
+ #########################
727
+ # Public Methods #
728
+ #########################
729
+
730
+ def get_record_by_id(self, record_id: RecordIdentifier) -> Record | None:
731
+ """Retrieves a record by its identifier.
732
+
733
+ Args:
734
+ record_id: The identifier of the record to retrieve.
735
+ Any type of RecordIdentifier will be accepted and resolved
736
+
737
+ Returns:
738
+ The record object if found, otherwise None.
739
+
740
+ Example:
741
+ ```python
742
+ record = client.records.get_record_by_id("record_uuid")
743
+ print(record.record_identifier if record else "missing")
744
+ ```
745
+ """
746
+ if isinstance(record_id, Record):
747
+ return record_id
748
+
749
+ if isinstance(record_id, str):
750
+ return self._get_record_by_uuid(record_id)
751
+ else:
752
+ return self._get_record_by_key_values(record_id)
753
+
754
+ def get_records_by_ids(
755
+ self, record_ids: List[RecordIdentifier], batch_size: int = 250
756
+ ) -> List[Record]:
757
+ """Retrieves records corresponding to the provided list of record IDs.
758
+
759
+ Args:
760
+ record_ids: A list of record IDs to retrieve.
761
+ batch_size: How many records retrieved with every API call. Defaults to 250.
762
+
763
+ Returns:
764
+ A list of Record objects corresponding to the provided IDs.
765
+
766
+ Example:
767
+ ```python
768
+ records = client.records.get_records_by_ids([
769
+ "record_uuid_1",
770
+ "record_uuid_2",
771
+ ])
772
+ print(len(records))
773
+ ```
774
+ """
775
+ try:
776
+ all_records = []
777
+
778
+ for batch in itertools.batched(record_ids, batch_size):
779
+ all_records.extend(self._get_records_in_order(list(batch)))
780
+
781
+ return [uuid for uuid in all_records if uuid]
782
+ except Exception as e:
783
+ _logger.error(f"Error fetching records {record_ids}: {e}")
784
+ return []
785
+
786
+ def get_or_create_record(
787
+ self, key_values: dict[EntityFieldIdentifier, str]
788
+ ) -> Record | None:
789
+ """Retrieves an existing record matching the provided key-value pairs, or creates a new one if none exists.
790
+
791
+ Args:
792
+ key_values: A dictionary containing key-value pairs to identify or create the record.
793
+
794
+ Returns:
795
+ The retrieved or newly created Record object if successful or None, if no record is found or created
796
+
797
+ Example:
798
+ ```python
799
+ record = client.records.get_or_create_record({"FIELD_ID": "KEY-123"})
800
+ print(record.record_identifier if record else "not created")
801
+ ```
802
+ """
803
+ try:
804
+ resolved_values = self._resolve_key_values(key_values)
805
+ except ValueError as e:
806
+ _logger.error(f"Invalid key fields: {e}")
807
+ return None
808
+
809
+ record_key = frozenset(resolved_values.items())
810
+
811
+ if record_key in self._records_key_field_map:
812
+ return self._records_key_field_map[record_key]
813
+
814
+ try:
815
+ resp = self._client._post(
816
+ "/records",
817
+ {"key_field_to_value": resolved_values},
818
+ )
819
+ if resp is None or len(resp) == 0:
820
+ return None
821
+
822
+ return self._create_record(resp)
823
+ except Exception as e:
824
+ _logger.error(f"Error creating record {key_values}: {e}")
825
+ return None
826
+
827
+ def search_records(self, **params: Unpack[SearchRecordsQuery]) -> list[str]:
828
+ """Searches for records using the provided query parameters.
829
+
830
+ Args:
831
+ **params: Keyword arguments representing search criteria. Non-string values will be JSON-encoded before being sent.
832
+
833
+ Returns:
834
+ A list of record identifiers matching the search criteria. Returns an empty list if the response is empty.
835
+
836
+ Note:
837
+ If an exception occurs during the API request, it logs the error and returns an empty list.
838
+
839
+ Example:
840
+ ```python
841
+ record_ids = client.records.search_records(search_text="cell line")
842
+ ```
843
+ """
844
+ try:
845
+ client_params = {
846
+ key: (value if isinstance(value, str) else json.dumps(value))
847
+ for key, value in params.items()
848
+ }
849
+ resp = self._client._get("/records/search", client_params)
850
+ if resp is None:
851
+ return []
852
+
853
+ return resp
854
+ except Exception as e:
855
+ _logger.error(f"Error searching records {params}: {e}")
856
+ return []
857
+
858
+ def create_record_value_file(
859
+ self,
860
+ record_id: RecordIdentifier,
861
+ field_id: str,
862
+ file_name: str,
863
+ file_data: BinaryIO,
864
+ file_type: str,
865
+ activity_id: Optional[str] = None,
866
+ ) -> RecordValue | None:
867
+ """Creates a record value for a file and uploads it to the specified record.
868
+
869
+ Args:
870
+ record_id: The identifier of the record to which the file value will be added.
871
+
872
+ Any type of RecordIdentifier will be accepted and resolved.
873
+ field_id: The identifier of the field associated with the file value.
874
+ file_name: The name of the file to be uploaded.
875
+ file_data: A binary stream representing the file data.
876
+ file_type: The MIME type of the file.
877
+ activity_id: An optional activity identifier.
878
+
879
+ Returns:
880
+ The created RecordValue object if successful, otherwise None.
881
+
882
+ Example:
883
+ ```python
884
+ with open("results.csv", "rb") as file_data:
885
+ value = client.records.create_record_value_file(
886
+ record_id="record_uuid",
887
+ field_id="file_field_uuid",
888
+ file_name="results.csv",
889
+ file_data=file_data,
890
+ file_type="text/csv",
891
+ )
892
+ ```
893
+ """
894
+ record_uuid = self._resolve_to_record_id(record_id)
895
+ if record_uuid is None:
896
+ return None
897
+
898
+ try:
899
+ body = {
900
+ "field_id": field_id,
901
+ }
902
+
903
+ if activity_id:
904
+ body["operation_id"] = activity_id
905
+
906
+ resp = self._client._post_file(
907
+ "/records/" + record_uuid + "/values/file",
908
+ (file_name, file_data, file_type),
909
+ body,
910
+ )
911
+
912
+ if resp is None or len(resp) == 0:
913
+ return None
914
+
915
+ return RecordValue.model_validate(resp.get("resource"))
916
+ except Exception as e:
917
+ _logger.error(f"Error uploading file to record field: {e}")
918
+ return None
919
+
920
+ def clear_record_caches(self):
921
+ """Clears all caches for Record objects.
922
+
923
+ Call whenever caches may be stale.
924
+
925
+ Note that all methods of Record automaticaly update the caches.
926
+ This is to be called if you would like your program to refetch the latest data from the API.
927
+
928
+ Example:
929
+ ```python
930
+ client.records.clear_record_caches()
931
+ ```
932
+ """
933
+ self._records_uuid_map.clear()
934
+ self._records_key_field_map.clear()
935
+ self._client.activities.get_activities_with_record.cache_clear()
936
+
937
+ #########################
938
+ # Private Methods #
939
+ #########################
940
+
941
+ def _create_record(self, data: dict) -> Record | None:
942
+ """Creates a new Record instance from the provided data.
943
+
944
+ Validates the input data using the Record model, sets the client for the record,
945
+ and adds it local record caches.
946
+
947
+ Args:
948
+ data: The data to be validated and used for creating the Record.
949
+
950
+ Returns:
951
+ The validated and initialized Record instance or None, if the data is invalid.
952
+ """
953
+ try:
954
+ record = Record.model_validate(data)
955
+ except ValidationError as e:
956
+ _logger.error(f"Failed to validate data as record: {e}")
957
+ return None
958
+
959
+ record._set_client(self._client)
960
+
961
+ self._records_uuid_map[record.id] = record
962
+ key = self.__record_to_hashable_key_fields(record)
963
+ self._records_key_field_map[key] = record
964
+
965
+ return record
966
+
967
+ def _create_record_list(self, data: list[dict]) -> List[Record | None]:
968
+ """Converts a list of record data into a list of record objects.
969
+
970
+ Each piece of data is validated as a Record, has the client set for the record,
971
+ and is added to local record caches.
972
+
973
+ Args:
974
+ data: The input data to be converted into Record objects.
975
+
976
+ Returns:
977
+ A list of Record objects with the client set.
978
+ """
979
+
980
+ return [self._create_record(r) for r in data]
981
+
982
+ def _resolve_key_values(
983
+ self, key_values: dict[EntityFieldIdentifier, str]
984
+ ) -> dict[str, str]:
985
+ """Resolves EntityFieldIdentifier of a dict of field-to-value pairings
986
+
987
+ Args:
988
+ key_values: the unresolved field-to-value pairings that identify a given record
989
+
990
+ Raises:
991
+ ValueError: If an EntityFieldIdentifier cannot be resolved
992
+
993
+ Returns:
994
+ The resolved field-to-value pairings
995
+ """
996
+ result = {}
997
+
998
+ for k, v in key_values.items():
999
+ key = self._client.entity_fields._resolve_key_field_id(k)
1000
+
1001
+ if key is None:
1002
+ raise ValueError(f"Invalid EntityFieldIdentifier {k}")
1003
+
1004
+ result[key] = v
1005
+
1006
+ return result
1007
+
1008
+ def __record_to_hashable_key_fields(
1009
+ self, record: Record
1010
+ ) -> frozenset[tuple[str, Any]]:
1011
+ """Gets a unique frozenset from a given record.
1012
+
1013
+ Args:
1014
+ record: the record to get the frozenset from
1015
+
1016
+ Returns:
1017
+ Frozenset of fields & values of a given record.
1018
+ """
1019
+ key_fields = set(record.identifier_ids)
1020
+
1021
+ # hash a record according to its key values
1022
+ return frozenset(
1023
+ (key_field_id, value[0].content)
1024
+ for key_field_id, value in record.record_values.items()
1025
+ if value and (value[0].id in key_fields)
1026
+ )
1027
+
1028
+ def _get_record_by_uuid(self, record_id: str) -> Record | None:
1029
+ """Retrieves a record by its uuid.
1030
+
1031
+ If corresponding record is cached, it is retrieved from the cache. Otherwise, it is fetched from the API.
1032
+
1033
+ Args:
1034
+ record_id: the uuid of a record
1035
+
1036
+ Returns:
1037
+ The corresponding record.
1038
+ """
1039
+ if record_id in self._records_uuid_map:
1040
+ return self._records_uuid_map[record_id]
1041
+
1042
+ try:
1043
+ resp = self._client._get("/records/" + record_id)
1044
+
1045
+ if resp is None:
1046
+ self._records_uuid_map[record_id] = None
1047
+ return None
1048
+
1049
+ return self._create_record(resp)
1050
+ except Exception as e:
1051
+ _logger.error(f"Error fetching record {id}: {e}")
1052
+ return None
1053
+
1054
+ def _get_record_by_key_values(
1055
+ self, key_values: dict[EntityFieldIdentifier, str]
1056
+ ) -> Record | None:
1057
+ """Retrieves a record by a corresponding field-to-value dict
1058
+
1059
+ Args:
1060
+ key_values: the field-to-value dict
1061
+
1062
+ Returns:
1063
+ the corresponding record
1064
+ """
1065
+ try:
1066
+ resolved_values = self._resolve_key_values(key_values)
1067
+ except ValueError as e:
1068
+ _logger.error(f"Invalid key fields: {e}")
1069
+ return None
1070
+
1071
+ key = frozenset(resolved_values.items())
1072
+
1073
+ if key in self._records_key_field_map:
1074
+ return self._records_key_field_map[key]
1075
+
1076
+ try:
1077
+ resp = self._client._get(
1078
+ "/records/identifiers",
1079
+ {"records_key_field_to_value": json.dumps([resolved_values])},
1080
+ )
1081
+
1082
+ if resp is None or len(resp) == 0:
1083
+ self._records_key_field_map[key] = None
1084
+ return None
1085
+
1086
+ result = resp[0].get("record")
1087
+ if not result:
1088
+ raise ValueError("Response is not valid record")
1089
+
1090
+ return self._create_record(result)
1091
+ except Exception as e:
1092
+ _logger.error(f"Error fetching records {key_values}: {e}")
1093
+ return None
1094
+
1095
+ def _resolve_to_record_id(
1096
+ self, identifier: RecordIdentifier | None, lazy: bool = False
1097
+ ) -> str | None:
1098
+ """Resolves a record identifier to its UUID.
1099
+
1100
+ Set `lazy` to true if uuids should not be validated.
1101
+
1102
+ Given a record type:
1103
+
1104
+ * A Record will have its uuid returned
1105
+ * A UUID will return itself
1106
+ * A field-to-value dict will be retrieved from cache or an API request
1107
+
1108
+ Args:
1109
+ identifier: resolves a RecordIdentifier, is nullable
1110
+ lazy: if lazy, then it will not ensure that the uuid is a valid uuid.
1111
+
1112
+ Returns:
1113
+ The record UUID if found, otherwise None.
1114
+ """
1115
+ if identifier is None:
1116
+ return None
1117
+
1118
+ if isinstance(identifier, Record):
1119
+ if lazy:
1120
+ return identifier.id
1121
+
1122
+ record = self._get_record_by_uuid(identifier.id)
1123
+ return record.id if record else None
1124
+
1125
+ if isinstance(identifier, str):
1126
+ return identifier
1127
+ else:
1128
+ record = self._get_record_by_key_values(identifier)
1129
+
1130
+ if record:
1131
+ return record.id
1132
+ else:
1133
+ return None
1134
+
1135
+ def _get_records_in_order(
1136
+ self, identifiers: list[RecordIdentifier]
1137
+ ) -> list[Record | None]:
1138
+ """Gets records in order. Invalid record identifiers are replaced with None, rather than being removed from the result.
1139
+
1140
+ Args:
1141
+ identifiers: a list of record identifiers to retrieve
1142
+
1143
+ Returns:
1144
+ The set of corresponding records, in the original order of the record identifiers.
1145
+ """
1146
+ resolved = [
1147
+ self._resolve_to_record_id(ident, lazy=True) for ident in identifiers
1148
+ ]
1149
+
1150
+ # fmt: off
1151
+ to_fetch = [
1152
+ uuid for uuid in resolved
1153
+ if uuid and uuid not in self._records_uuid_map
1154
+ ]
1155
+ # fmt: on
1156
+
1157
+ resp = self._client._get(f"/records?record_ids={",".join(to_fetch)}") or []
1158
+ self._create_record_list(resp)
1159
+
1160
+ ordered = [
1161
+ self._records_uuid_map.get(uuid) if uuid else None for uuid in resolved
1162
+ ]
1163
+
1164
+ return ordered
1165
+
1166
+ def _clear_record_from_caches(self, record: Record):
1167
+ """Removes a given record from the record service caches
1168
+
1169
+ Call when a record is updated."""
1170
+ self._records_uuid_map.pop(record.id, None)
1171
+ self._records_key_field_map.pop(
1172
+ self.__record_to_hashable_key_fields(record), None
1173
+ )