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/__init__.py +1 -0
- kalbio/_kaleidoscope_model.py +111 -0
- kalbio/activities.py +1202 -0
- kalbio/client.py +463 -0
- kalbio/dashboards.py +276 -0
- kalbio/entity_fields.py +474 -0
- kalbio/entity_types.py +188 -0
- kalbio/exports.py +126 -0
- kalbio/helpers.py +52 -0
- kalbio/imports.py +89 -0
- kalbio/labels.py +88 -0
- kalbio/programs.py +96 -0
- kalbio/property_fields.py +81 -0
- kalbio/record_views.py +191 -0
- kalbio/records.py +1173 -0
- kalbio/workspace.py +315 -0
- kalbio-0.2.0.dist-info/METADATA +289 -0
- kalbio-0.2.0.dist-info/RECORD +19 -0
- kalbio-0.2.0.dist-info/WHEEL +4 -0
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
|
+
)
|