liminal-orm 1.1.4__py3-none-any.whl → 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- liminal/base/base_operation.py +4 -3
- liminal/base/base_validation_filters.py +15 -0
- liminal/base/name_template_parts.py +9 -0
- liminal/base/properties/base_field_properties.py +2 -2
- liminal/base/properties/base_name_template.py +83 -0
- liminal/base/properties/base_schema_properties.py +13 -1
- liminal/dropdowns/compare.py +8 -0
- liminal/dropdowns/operations.py +1 -1
- liminal/entity_schemas/api.py +18 -0
- liminal/entity_schemas/compare.py +62 -8
- liminal/entity_schemas/entity_schema_models.py +43 -0
- liminal/entity_schemas/generate_files.py +13 -11
- liminal/entity_schemas/operations.py +43 -18
- liminal/entity_schemas/tag_schema_models.py +146 -3
- liminal/entity_schemas/utils.py +15 -2
- liminal/enums/__init__.py +0 -1
- liminal/enums/benchling_entity_type.py +8 -0
- liminal/enums/name_template_part_type.py +12 -0
- liminal/external/__init__.py +11 -1
- liminal/migrate/revisions_timeline.py +2 -1
- liminal/orm/base_model.py +90 -29
- liminal/orm/name_template.py +39 -0
- liminal/orm/name_template_parts.py +96 -0
- liminal/orm/schema_properties.py +27 -1
- liminal/tests/conftest.py +18 -9
- liminal/tests/test_entity_schema_compare.py +61 -12
- liminal/utils.py +9 -0
- liminal/validation/__init__.py +84 -108
- liminal/{enums/benchling_report_level.py → validation/validation_severity.py} +2 -2
- {liminal_orm-1.1.4.dist-info → liminal_orm-2.0.1.dist-info}/METADATA +17 -20
- {liminal_orm-1.1.4.dist-info → liminal_orm-2.0.1.dist-info}/RECORD +34 -29
- {liminal_orm-1.1.4.dist-info → liminal_orm-2.0.1.dist-info}/LICENSE.md +0 -0
- {liminal_orm-1.1.4.dist-info → liminal_orm-2.0.1.dist-info}/WHEEL +0 -0
- {liminal_orm-1.1.4.dist-info → liminal_orm-2.0.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,96 @@
|
|
1
|
+
from typing import Any, ClassVar
|
2
|
+
|
3
|
+
from pydantic import BaseModel, ConfigDict, field_validator
|
4
|
+
|
5
|
+
from liminal.enums.name_template_part_type import NameTemplatePartType
|
6
|
+
|
7
|
+
|
8
|
+
class NameTemplatePart(BaseModel):
|
9
|
+
"""Base class for all name template parts. These are put together in a list (where order matters) to form a name template.
|
10
|
+
|
11
|
+
Parameters
|
12
|
+
----------
|
13
|
+
component_type : NameTemplatePartType
|
14
|
+
The type of the component. One of the values in the NameTemplatePartType enum.
|
15
|
+
|
16
|
+
"""
|
17
|
+
|
18
|
+
component_type: ClassVar[NameTemplatePartType]
|
19
|
+
|
20
|
+
_type_map: ClassVar[dict[NameTemplatePartType, type["NameTemplatePart"]]] = {}
|
21
|
+
|
22
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
23
|
+
|
24
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
25
|
+
super().__init_subclass__(**kwargs)
|
26
|
+
cls._type_map[cls.component_type] = cls
|
27
|
+
|
28
|
+
@classmethod
|
29
|
+
def resolve_type(cls, type: NameTemplatePartType) -> type["NameTemplatePart"]:
|
30
|
+
if type not in cls._type_map:
|
31
|
+
raise ValueError(f"Invalid name template part type: {type}")
|
32
|
+
return cls._type_map[type]
|
33
|
+
|
34
|
+
|
35
|
+
class SeparatorPart(NameTemplatePart):
|
36
|
+
component_type: ClassVar[NameTemplatePartType] = NameTemplatePartType.SEPARATOR
|
37
|
+
value: str
|
38
|
+
|
39
|
+
@field_validator("value")
|
40
|
+
def validate_value(cls, v: str) -> str:
|
41
|
+
if not v:
|
42
|
+
raise ValueError("value cannot be empty")
|
43
|
+
return v
|
44
|
+
|
45
|
+
|
46
|
+
class TextPart(NameTemplatePart):
|
47
|
+
component_type: ClassVar[NameTemplatePartType] = NameTemplatePartType.TEXT
|
48
|
+
value: str
|
49
|
+
|
50
|
+
@field_validator("value")
|
51
|
+
def validate_value(cls, v: str) -> str:
|
52
|
+
if not v:
|
53
|
+
raise ValueError("value cannot be empty")
|
54
|
+
return v
|
55
|
+
|
56
|
+
|
57
|
+
class CreationYearPart(NameTemplatePart):
|
58
|
+
component_type: ClassVar[NameTemplatePartType] = NameTemplatePartType.CREATION_YEAR
|
59
|
+
|
60
|
+
|
61
|
+
class CreationDatePart(NameTemplatePart):
|
62
|
+
component_type: ClassVar[NameTemplatePartType] = NameTemplatePartType.CREATION_DATE
|
63
|
+
|
64
|
+
|
65
|
+
class FieldPart(NameTemplatePart):
|
66
|
+
component_type: ClassVar[NameTemplatePartType] = NameTemplatePartType.FIELD
|
67
|
+
wh_field_name: str
|
68
|
+
|
69
|
+
|
70
|
+
class ParentLotNumberPart(NameTemplatePart):
|
71
|
+
component_type: ClassVar[NameTemplatePartType] = (
|
72
|
+
NameTemplatePartType.CHILD_ENTITY_LOT_NUMBER
|
73
|
+
)
|
74
|
+
wh_field_name: str
|
75
|
+
|
76
|
+
|
77
|
+
class RegistryIdentifierNumberPart(NameTemplatePart):
|
78
|
+
component_type: ClassVar[NameTemplatePartType] = (
|
79
|
+
NameTemplatePartType.REGISTRY_IDENTIFIER_NUMBER
|
80
|
+
)
|
81
|
+
|
82
|
+
|
83
|
+
class ProjectPart(NameTemplatePart):
|
84
|
+
component_type: ClassVar[NameTemplatePartType] = NameTemplatePartType.PROJECT
|
85
|
+
|
86
|
+
|
87
|
+
NameTemplateParts = (
|
88
|
+
SeparatorPart
|
89
|
+
| TextPart
|
90
|
+
| CreationYearPart
|
91
|
+
| CreationDatePart
|
92
|
+
| FieldPart
|
93
|
+
| RegistryIdentifierNumberPart
|
94
|
+
| ProjectPart
|
95
|
+
| ParentLotNumberPart
|
96
|
+
)
|
liminal/orm/schema_properties.py
CHANGED
@@ -16,6 +16,30 @@ class SchemaProperties(BaseSchemaProperties):
|
|
16
16
|
"""
|
17
17
|
This class is the validated class that is public facing and inherits from the BaseSchemaProperties class.
|
18
18
|
It has the same fields as the BaseSchemaProperties class, but it is validated to ensure that the fields are valid.
|
19
|
+
|
20
|
+
Parameters
|
21
|
+
----------
|
22
|
+
name : str
|
23
|
+
The name of the schema.
|
24
|
+
warehouse_name : str
|
25
|
+
The sql table name of the schema in the benchling warehouse.
|
26
|
+
prefix : str
|
27
|
+
The prefix to use for the schema.
|
28
|
+
entity_type : BenchlingEntityType
|
29
|
+
The entity type of the schema.
|
30
|
+
naming_strategies : set[BenchlingNamingStrategy]
|
31
|
+
The naming strategies of the schema.
|
32
|
+
mixture_schema_config : MixtureSchemaConfig | None
|
33
|
+
The mixture schema config of the schema.
|
34
|
+
use_registry_id_as_label : bool | None = None
|
35
|
+
Flag for configuring the chip label for entities. Determines if the chip will use the Registry ID as the main label for items.
|
36
|
+
include_registry_id_in_chips : bool | None = None
|
37
|
+
Flag for configuring the chip label for entities. Determines if the chip will include the Registry ID in the chip label.
|
38
|
+
constraint_fields : set[str] | None
|
39
|
+
Set of constraints for field values for the schema. Must be a set of column names that specify that their values must be a unique combination within an entity.
|
40
|
+
If the entity type is a Sequence, "bases" can be a constraint field.
|
41
|
+
_archived : bool | None
|
42
|
+
Whether the schema is archived in Benchling.
|
19
43
|
"""
|
20
44
|
|
21
45
|
name: str
|
@@ -23,7 +47,10 @@ class SchemaProperties(BaseSchemaProperties):
|
|
23
47
|
prefix: str
|
24
48
|
entity_type: BenchlingEntityType
|
25
49
|
naming_strategies: set[BenchlingNamingStrategy]
|
50
|
+
use_registry_id_as_label: bool | None = False
|
51
|
+
include_registry_id_in_chips: bool | None = False
|
26
52
|
mixture_schema_config: MixtureSchemaConfig | None = None
|
53
|
+
constraint_fields: set[str] | None = None
|
27
54
|
_archived: bool = False
|
28
55
|
|
29
56
|
def __init__(self, **data: Any):
|
@@ -53,7 +80,6 @@ class SchemaProperties(BaseSchemaProperties):
|
|
53
80
|
)
|
54
81
|
is_valid_wh_name(self.warehouse_name)
|
55
82
|
is_valid_prefix(self.prefix)
|
56
|
-
# TODO:if naming_strategies contains SET_FROM_NAME_PARTS or REPLACE_NAMES_FROM_PARTS, name template for schema must be set
|
57
83
|
return self
|
58
84
|
|
59
85
|
def set_archived(self, value: bool) -> SchemaProperties:
|
liminal/tests/conftest.py
CHANGED
@@ -12,6 +12,7 @@ from liminal.enums import BenchlingFieldType as Type
|
|
12
12
|
from liminal.enums.benchling_naming_strategy import BenchlingNamingStrategy
|
13
13
|
from liminal.orm.base_model import BaseModel
|
14
14
|
from liminal.orm.column import Column
|
15
|
+
from liminal.orm.name_template import NameTemplate
|
15
16
|
from liminal.orm.schema_properties import SchemaProperties
|
16
17
|
|
17
18
|
FIXTURES = Path(__file__).parent / "fixtures"
|
@@ -118,13 +119,16 @@ def mock_benchling_dropdowns_archived() -> dict[str, Dropdown]:
|
|
118
119
|
@pytest.fixture
|
119
120
|
def mock_benchling_schema(
|
120
121
|
mock_benchling_dropdown: type[BaseDropdown],
|
121
|
-
) -> list[tuple[SchemaProperties, dict[str, Props]]]:
|
122
|
+
) -> list[tuple[SchemaProperties, NameTemplate, dict[str, Props]]]:
|
122
123
|
schema_props = SchemaProperties(
|
123
124
|
name="Mock Entity",
|
124
125
|
warehouse_name="mock_entity",
|
125
126
|
prefix="MockEntity",
|
126
127
|
entity_type=BenchlingEntityType.CUSTOM_ENTITY,
|
127
128
|
naming_strategies=[BenchlingNamingStrategy.NEW_IDS],
|
129
|
+
use_registry_id_as_label=True,
|
130
|
+
include_registry_id_in_chips=False,
|
131
|
+
constraint_fields={"enum_field", "string_field_req"},
|
128
132
|
)
|
129
133
|
fields = {
|
130
134
|
"enum_field": Props(
|
@@ -234,13 +238,13 @@ def mock_benchling_schema(
|
|
234
238
|
_archived=False,
|
235
239
|
),
|
236
240
|
}
|
237
|
-
return [(schema_props, fields)]
|
241
|
+
return [(schema_props, NameTemplate(), fields)]
|
238
242
|
|
239
243
|
|
240
244
|
@pytest.fixture
|
241
245
|
def mock_benchling_schema_one(
|
242
246
|
mock_benchling_dropdown: type[BaseDropdown],
|
243
|
-
) -> list[tuple[SchemaProperties, dict[str, Props]]]:
|
247
|
+
) -> list[tuple[SchemaProperties, NameTemplate, dict[str, Props]]]:
|
244
248
|
schema_props = SchemaProperties(
|
245
249
|
name="Mock Entity One",
|
246
250
|
warehouse_name="mock_entity_one",
|
@@ -301,11 +305,13 @@ def mock_benchling_schema_one(
|
|
301
305
|
_archived=False,
|
302
306
|
),
|
303
307
|
}
|
304
|
-
return [(schema_props, fields)]
|
308
|
+
return [(schema_props, NameTemplate(), fields)]
|
305
309
|
|
306
310
|
|
307
311
|
@pytest.fixture
|
308
|
-
def mock_benchling_schema_archived() ->
|
312
|
+
def mock_benchling_schema_archived() -> (
|
313
|
+
list[tuple[SchemaProperties, NameTemplate, dict[str, Props]]]
|
314
|
+
):
|
309
315
|
schema_props = SchemaProperties(
|
310
316
|
name="Mock Entity Small",
|
311
317
|
warehouse_name="mock_entity_small",
|
@@ -335,7 +341,7 @@ def mock_benchling_schema_archived() -> list[tuple[SchemaProperties, dict[str, P
|
|
335
341
|
_archived=False,
|
336
342
|
),
|
337
343
|
}
|
338
|
-
return [(schema_props, fields)]
|
344
|
+
return [(schema_props, NameTemplate(), fields)]
|
339
345
|
|
340
346
|
|
341
347
|
@pytest.fixture
|
@@ -347,6 +353,9 @@ def mock_benchling_subclass(mock_benchling_dropdown) -> list[type[BaseModel]]:
|
|
347
353
|
prefix="MockEntity",
|
348
354
|
entity_type=BenchlingEntityType.CUSTOM_ENTITY,
|
349
355
|
naming_strategies=[BenchlingNamingStrategy.NEW_IDS],
|
356
|
+
use_registry_id_as_label=True,
|
357
|
+
include_registry_id_in_chips=False,
|
358
|
+
constraint_fields={"enum_field", "string_field_req"},
|
350
359
|
)
|
351
360
|
enum_field: SqlColumn = Column(
|
352
361
|
name="Enum Field",
|
@@ -420,7 +429,7 @@ def mock_benchling_subclass(mock_benchling_dropdown) -> list[type[BaseModel]]:
|
|
420
429
|
self.datetime_field = datetime_field
|
421
430
|
self.list_dropdown_field = list_dropdown_field
|
422
431
|
|
423
|
-
return [MockEntity]
|
432
|
+
return [MockEntity]
|
424
433
|
|
425
434
|
|
426
435
|
@pytest.fixture
|
@@ -437,7 +446,7 @@ def mock_benchling_subclass_small() -> list[type[BaseModel]]:
|
|
437
446
|
name="String Field Required", type=Type.TEXT, required=True, is_multi=False
|
438
447
|
)
|
439
448
|
|
440
|
-
return [MockEntitySmall]
|
449
|
+
return [MockEntitySmall]
|
441
450
|
|
442
451
|
|
443
452
|
@pytest.fixture
|
@@ -530,4 +539,4 @@ def mock_benchling_subclasses(mock_benchling_dropdown) -> list[type[BaseModel]]:
|
|
530
539
|
self.datetime_field = datetime_field
|
531
540
|
self.list_dropdown_field = list_dropdown_field
|
532
541
|
|
533
|
-
return [MockEntityOne, MockEntityTwo]
|
542
|
+
return [MockEntityOne, MockEntityTwo]
|
@@ -13,8 +13,11 @@ from liminal.entity_schemas.operations import (
|
|
13
13
|
UnarchiveEntitySchemaField,
|
14
14
|
UpdateEntitySchema,
|
15
15
|
UpdateEntitySchemaField,
|
16
|
+
UpdateEntitySchemaNameTemplate,
|
16
17
|
)
|
17
18
|
from liminal.enums import BenchlingFieldType
|
19
|
+
from liminal.orm.name_template import NameTemplate
|
20
|
+
from liminal.orm.name_template_parts import TextPart
|
18
21
|
|
19
22
|
|
20
23
|
class TestCompareEntitySchemas:
|
@@ -145,7 +148,7 @@ class TestCompareEntitySchemas:
|
|
145
148
|
|
146
149
|
# Test when the Benchling schema is missing a field compared to the table model
|
147
150
|
missing_field = copy.deepcopy(mock_benchling_schema)
|
148
|
-
missing_field[0][
|
151
|
+
missing_field[0][2].pop("string_field_req")
|
149
152
|
mock_get_benchling_entity_schemas.return_value = missing_field
|
150
153
|
invalid_models = compare_entity_schemas(mock_benchling_sdk)
|
151
154
|
assert len(invalid_models["mock_entity"]) == 1
|
@@ -162,7 +165,7 @@ class TestCompareEntitySchemas:
|
|
162
165
|
|
163
166
|
# Test when the Benchling schema has an extra field compared to the table model
|
164
167
|
extra_field = copy.deepcopy(mock_benchling_schema)
|
165
|
-
extra_field[0][
|
168
|
+
extra_field[0][2]["extra_field"] = BaseFieldProperties(
|
166
169
|
name="Extra Field",
|
167
170
|
type=BenchlingFieldType.TEXT,
|
168
171
|
required=False,
|
@@ -181,7 +184,7 @@ class TestCompareEntitySchemas:
|
|
181
184
|
|
182
185
|
# Test when the Benchling schema has a required field and the model field is nullable (not required)
|
183
186
|
benchling_switched_required_field = copy.deepcopy(mock_benchling_schema)
|
184
|
-
benchling_switched_required_field[0][
|
187
|
+
benchling_switched_required_field[0][2]["enum_field"].required = True
|
185
188
|
mock_get_benchling_entity_schemas.return_value = (
|
186
189
|
benchling_switched_required_field
|
187
190
|
)
|
@@ -198,7 +201,7 @@ class TestCompareEntitySchemas:
|
|
198
201
|
|
199
202
|
# Test when the Benchling schema has a non required field and the model field is not nullable (required)
|
200
203
|
benchling_switched_required_field = copy.deepcopy(mock_benchling_schema)
|
201
|
-
benchling_switched_required_field[0][
|
204
|
+
benchling_switched_required_field[0][2]["string_field_req"].required = False
|
202
205
|
mock_get_benchling_entity_schemas.return_value = (
|
203
206
|
benchling_switched_required_field
|
204
207
|
)
|
@@ -215,7 +218,7 @@ class TestCompareEntitySchemas:
|
|
215
218
|
|
216
219
|
# Test when the Benchling schema has a non multi field and the model field is a list
|
217
220
|
benchling_switched_multi_field = copy.deepcopy(mock_benchling_schema)
|
218
|
-
benchling_switched_multi_field[0][
|
221
|
+
benchling_switched_multi_field[0][2]["list_dropdown_field"].is_multi = False
|
219
222
|
mock_get_benchling_entity_schemas.return_value = (
|
220
223
|
benchling_switched_multi_field
|
221
224
|
)
|
@@ -232,7 +235,7 @@ class TestCompareEntitySchemas:
|
|
232
235
|
|
233
236
|
# Test when the Benchling schema has a multi field and the model field is not a list
|
234
237
|
benchling_switched_multi_field = copy.deepcopy(mock_benchling_schema)
|
235
|
-
benchling_switched_multi_field[0][
|
238
|
+
benchling_switched_multi_field[0][2]["enum_field"].is_multi = True
|
236
239
|
mock_get_benchling_entity_schemas.return_value = (
|
237
240
|
benchling_switched_multi_field
|
238
241
|
)
|
@@ -249,7 +252,7 @@ class TestCompareEntitySchemas:
|
|
249
252
|
|
250
253
|
# Test when the multi field in the Benchling schema has a different entity type than the model field
|
251
254
|
benchling_false_entity_type = copy.deepcopy(mock_benchling_schema)
|
252
|
-
benchling_false_entity_type[0][
|
255
|
+
benchling_false_entity_type[0][2][
|
253
256
|
"list_dropdown_field"
|
254
257
|
].type = BenchlingFieldType.INTEGER
|
255
258
|
mock_get_benchling_entity_schemas.return_value = benchling_false_entity_type
|
@@ -266,7 +269,7 @@ class TestCompareEntitySchemas:
|
|
266
269
|
|
267
270
|
# Test when enum field in the Benchling schema has a different enum than the model field
|
268
271
|
benchling_false_enum = copy.deepcopy(mock_benchling_schema)
|
269
|
-
benchling_false_enum[0][
|
272
|
+
benchling_false_enum[0][2][
|
270
273
|
"enum_field"
|
271
274
|
].dropdown_link = mock_false_benchling_dropdown.__benchling_name__
|
272
275
|
mock_get_benchling_entity_schemas.return_value = benchling_false_enum
|
@@ -299,7 +302,7 @@ class TestCompareEntitySchemas:
|
|
299
302
|
|
300
303
|
# Test when the Benchling schema has an archived field that is in the model
|
301
304
|
create_existing_field_schema = copy.deepcopy(mock_benchling_schema)
|
302
|
-
create_existing_field_schema[0][
|
305
|
+
create_existing_field_schema[0][2]["string_field_req"].set_archived(True)
|
303
306
|
mock_get_benchling_entity_schemas.return_value = (
|
304
307
|
create_existing_field_schema
|
305
308
|
)
|
@@ -311,7 +314,7 @@ class TestCompareEntitySchemas:
|
|
311
314
|
|
312
315
|
# Test when Benchling schema fields are out of order
|
313
316
|
benchling_unordered_fields_schema = copy.deepcopy(mock_benchling_schema)
|
314
|
-
fields = benchling_unordered_fields_schema[0][
|
317
|
+
fields = benchling_unordered_fields_schema[0][2]
|
315
318
|
keys = list(fields.keys())
|
316
319
|
idx1, idx2 = (
|
317
320
|
keys.index("string_field_req"),
|
@@ -320,7 +323,7 @@ class TestCompareEntitySchemas:
|
|
320
323
|
keys[idx1], keys[idx2] = keys[idx2], keys[idx1]
|
321
324
|
new_fields = {k: fields[k] for k in keys}
|
322
325
|
new_benchling_unordered_fields_schema = [
|
323
|
-
(benchling_unordered_fields_schema[0][0], new_fields)
|
326
|
+
(benchling_unordered_fields_schema[0][0], NameTemplate(), new_fields)
|
324
327
|
]
|
325
328
|
mock_get_benchling_entity_schemas.return_value = (
|
326
329
|
new_benchling_unordered_fields_schema
|
@@ -333,7 +336,7 @@ class TestCompareEntitySchemas:
|
|
333
336
|
|
334
337
|
# Test when the Benchling schema archived field becomes unarchived
|
335
338
|
benchling_rearchived_field = copy.deepcopy(mock_benchling_schema)
|
336
|
-
benchling_rearchived_field[0][
|
339
|
+
benchling_rearchived_field[0][2]["archived_field"].set_archived(False)
|
337
340
|
mock_get_benchling_entity_schemas.return_value = benchling_rearchived_field
|
338
341
|
invalid_models = compare_entity_schemas(mock_benchling_sdk)
|
339
342
|
assert len(invalid_models["mock_entity"]) == 1
|
@@ -341,3 +344,49 @@ class TestCompareEntitySchemas:
|
|
341
344
|
invalid_models["mock_entity"][0].op, ArchiveEntitySchemaField
|
342
345
|
)
|
343
346
|
assert invalid_models["mock_entity"][0].op.wh_field_name == "archived_field"
|
347
|
+
|
348
|
+
# Test when the Benchling schema has different constraint fields
|
349
|
+
benchling_mismatch_constraint_fields = copy.deepcopy(mock_benchling_schema)
|
350
|
+
benchling_mismatch_constraint_fields[0][0].constraint_fields = {
|
351
|
+
"string_field_req"
|
352
|
+
}
|
353
|
+
mock_get_benchling_entity_schemas.return_value = (
|
354
|
+
benchling_mismatch_constraint_fields
|
355
|
+
)
|
356
|
+
invalid_models = compare_entity_schemas(mock_benchling_sdk)
|
357
|
+
assert len(invalid_models["mock_entity"]) == 1
|
358
|
+
assert isinstance(invalid_models["mock_entity"][0].op, UpdateEntitySchema)
|
359
|
+
assert invalid_models["mock_entity"][
|
360
|
+
0
|
361
|
+
].op.update_props.constraint_fields == {
|
362
|
+
"string_field_req",
|
363
|
+
"enum_field",
|
364
|
+
}
|
365
|
+
|
366
|
+
# Test when the Benchling schema has different display naming fields
|
367
|
+
benchling_mismatch_display_fields = copy.deepcopy(mock_benchling_schema)
|
368
|
+
benchling_mismatch_display_fields[0][0].use_registry_id_as_label = False
|
369
|
+
mock_get_benchling_entity_schemas.return_value = (
|
370
|
+
benchling_mismatch_display_fields
|
371
|
+
)
|
372
|
+
invalid_models = compare_entity_schemas(mock_benchling_sdk)
|
373
|
+
assert len(invalid_models["mock_entity"]) == 1
|
374
|
+
assert isinstance(invalid_models["mock_entity"][0].op, UpdateEntitySchema)
|
375
|
+
assert invalid_models["mock_entity"][
|
376
|
+
0
|
377
|
+
].op.update_props.use_registry_id_as_label
|
378
|
+
|
379
|
+
# Test when the Benchling schema has a name template and the schema defined in code does not
|
380
|
+
benchling_mismatch_display_fields = copy.deepcopy(mock_benchling_schema)
|
381
|
+
benchling_mismatch_display_fields[0][1].parts = [
|
382
|
+
TextPart(value="name_template_text")
|
383
|
+
]
|
384
|
+
mock_get_benchling_entity_schemas.return_value = (
|
385
|
+
benchling_mismatch_display_fields
|
386
|
+
)
|
387
|
+
invalid_models = compare_entity_schemas(mock_benchling_sdk)
|
388
|
+
assert len(invalid_models["mock_entity"]) == 1
|
389
|
+
assert isinstance(
|
390
|
+
invalid_models["mock_entity"][0].op, UpdateEntitySchemaNameTemplate
|
391
|
+
)
|
392
|
+
assert invalid_models["mock_entity"][0].op.update_name_template.parts == []
|
liminal/utils.py
CHANGED
@@ -15,6 +15,9 @@ def generate_random_id(length: int = 8) -> str:
|
|
15
15
|
|
16
16
|
|
17
17
|
def pascalize(input_string: str) -> str:
|
18
|
+
"""
|
19
|
+
Convert a string to PascalCase.
|
20
|
+
"""
|
18
21
|
return "".join(
|
19
22
|
re.sub(r"[\[\]{}():]", "", word).capitalize()
|
20
23
|
for word in re.split(r"[ /_\-]", input_string)
|
@@ -22,6 +25,9 @@ def pascalize(input_string: str) -> str:
|
|
22
25
|
|
23
26
|
|
24
27
|
def to_snake_case(input_string: str | None) -> str:
|
28
|
+
"""
|
29
|
+
Convert a string to snake_case.
|
30
|
+
"""
|
25
31
|
if input_string is None:
|
26
32
|
return ""
|
27
33
|
return "_".join(
|
@@ -31,6 +37,9 @@ def to_snake_case(input_string: str | None) -> str:
|
|
31
37
|
|
32
38
|
|
33
39
|
def to_string_val(input_val: Any) -> str:
|
40
|
+
"""
|
41
|
+
Convert a value to a string.
|
42
|
+
"""
|
34
43
|
if isinstance(input_val, list) or isinstance(input_val, set):
|
35
44
|
return f'[{", ".join(input_val)}]'
|
36
45
|
return str(input_val)
|
liminal/validation/__init__.py
CHANGED
@@ -1,13 +1,15 @@
|
|
1
|
-
|
1
|
+
import inspect
|
2
2
|
from datetime import datetime
|
3
|
-
from
|
3
|
+
from functools import wraps
|
4
|
+
from typing import TYPE_CHECKING, Callable
|
4
5
|
|
5
6
|
from pydantic import BaseModel, ConfigDict
|
6
7
|
|
7
|
-
from liminal.
|
8
|
+
from liminal.utils import pascalize
|
9
|
+
from liminal.validation.validation_severity import ValidationSeverity
|
8
10
|
|
9
11
|
if TYPE_CHECKING:
|
10
|
-
from liminal.orm.base_model import BaseModel as
|
12
|
+
from liminal.orm.base_model import BaseModel as BenchlingBaseModel
|
11
13
|
|
12
14
|
|
13
15
|
class BenchlingValidatorReport(BaseModel):
|
@@ -20,7 +22,7 @@ class BenchlingValidatorReport(BaseModel):
|
|
20
22
|
Indicates whether the validation passed or failed.
|
21
23
|
model : str
|
22
24
|
The name of the model being validated. (eg: NGSSample)
|
23
|
-
level :
|
25
|
+
level : ValidationSeverity
|
24
26
|
The severity level of the validation report.
|
25
27
|
validator_name : str | None
|
26
28
|
The name of the validator that generated this report. (eg: BioContextValidator)
|
@@ -44,7 +46,7 @@ class BenchlingValidatorReport(BaseModel):
|
|
44
46
|
|
45
47
|
valid: bool
|
46
48
|
model: str
|
47
|
-
level:
|
49
|
+
level: ValidationSeverity
|
48
50
|
validator_name: str | None = None
|
49
51
|
entity_id: str | None = None
|
50
52
|
registry_id: str | None = None
|
@@ -57,89 +59,35 @@ class BenchlingValidatorReport(BaseModel):
|
|
57
59
|
|
58
60
|
model_config = ConfigDict(extra="allow")
|
59
61
|
|
60
|
-
|
61
|
-
class BenchlingValidator(ABC):
|
62
|
-
"""Base class for benchling validators."""
|
63
|
-
|
64
|
-
def __str__(self) -> str:
|
65
|
-
return self.__class__.__name__ + "()"
|
66
|
-
|
67
|
-
def _prefix(self) -> str:
|
68
|
-
"""Creates a prefix for the formatted error message which includes the class name and any instance variables.
|
69
|
-
Ex: "BenchlingValidator(field_name=sample_code, field_value=123):"
|
70
|
-
"""
|
71
|
-
prefix = f"{self.__class__.__name__}"
|
72
|
-
if vars(self):
|
73
|
-
prefix += "("
|
74
|
-
for key, val in vars(self).items():
|
75
|
-
prefix += f"{key}={self.truncate_msg(val, max_len=50)}, "
|
76
|
-
prefix = prefix[:-2] + "):"
|
77
|
-
else:
|
78
|
-
prefix += ":"
|
79
|
-
return prefix
|
80
|
-
|
81
|
-
@abstractmethod
|
82
|
-
def validate(self, entity: type["BaseModelBenchling"]) -> BenchlingValidatorReport:
|
83
|
-
"""Abstract method that all validator subclass must implement. Each subclass will have a differently defined validation
|
84
|
-
function that runs on the given benchling entity.
|
85
|
-
|
86
|
-
Parameters
|
87
|
-
----------
|
88
|
-
entity : type["BaseModelBenchling"]
|
89
|
-
The Benchling entity to validate.
|
90
|
-
|
91
|
-
Returns
|
92
|
-
-------
|
93
|
-
BenchlingValidatorReport
|
94
|
-
A report indicating whether the validation passed or failed, and any additional metadata.
|
95
|
-
"""
|
96
|
-
raise NotImplementedError
|
97
|
-
|
98
|
-
def __getattribute__(self, name: str) -> Any:
|
99
|
-
attr = super().__getattribute__(name)
|
100
|
-
if name == "validate":
|
101
|
-
# Wrap the validate method in a try-except block to catch any unexpected errors that occur during validation.
|
102
|
-
# If an unexpected error occurs, return a BenchlingValidatorReport with the unexpected error message.
|
103
|
-
def try_except_wrapped_func(
|
104
|
-
*args: Any, **kwargs: dict
|
105
|
-
) -> BenchlingValidatorReport:
|
106
|
-
try:
|
107
|
-
return attr(*args, **kwargs)
|
108
|
-
except Exception as e:
|
109
|
-
entity: type[BaseModelBenchling] = args[0]
|
110
|
-
return BenchlingValidatorReport(
|
111
|
-
valid=False,
|
112
|
-
model=entity.__class__.__name__,
|
113
|
-
validator_name=self.__class__.__name__,
|
114
|
-
level=BenchlingReportLevel.UNEXPECTED,
|
115
|
-
entity_id=entity.id,
|
116
|
-
registry_id=entity.file_registry_id,
|
117
|
-
entity_name=entity.name,
|
118
|
-
web_url=entity.url if entity.url else None,
|
119
|
-
creator_name=entity.creator.name if entity.creator else None,
|
120
|
-
creator_email=entity.creator.email if entity.creator else None,
|
121
|
-
updated_date=entity.modified_at,
|
122
|
-
message=f"Unexpected exception: {e}",
|
123
|
-
)
|
124
|
-
|
125
|
-
return try_except_wrapped_func
|
126
|
-
return attr
|
127
|
-
|
128
62
|
@classmethod
|
129
|
-
def
|
63
|
+
def create_validation_report(
|
130
64
|
cls,
|
131
65
|
valid: bool,
|
132
|
-
level:
|
133
|
-
entity: type["
|
66
|
+
level: ValidationSeverity,
|
67
|
+
entity: type["BenchlingBaseModel"],
|
68
|
+
validator_name: str,
|
134
69
|
message: str | None = None,
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
70
|
+
) -> "BenchlingValidatorReport":
|
71
|
+
"""Creates a BenchlingValidatorReport with the given parameters.
|
72
|
+
|
73
|
+
Parameters
|
74
|
+
----------
|
75
|
+
valid: bool
|
76
|
+
Indicates whether the validation passed or failed.
|
77
|
+
level: ValidationSeverity
|
78
|
+
The severity level of the validation report.
|
79
|
+
entity: type[BenchlingBaseModel]
|
80
|
+
The entity being validated.
|
81
|
+
validator_name: str
|
82
|
+
The name of the validator that generated this report.
|
83
|
+
message: str | None
|
84
|
+
A message describing the result of the validation.
|
85
|
+
"""
|
86
|
+
return cls(
|
139
87
|
valid=valid,
|
140
88
|
level=level,
|
141
89
|
model=entity.__class__.__name__,
|
142
|
-
validator_name=
|
90
|
+
validator_name=validator_name,
|
143
91
|
entity_id=entity.id,
|
144
92
|
registry_id=entity.file_registry_id,
|
145
93
|
entity_name=entity.name,
|
@@ -148,31 +96,59 @@ class BenchlingValidator(ABC):
|
|
148
96
|
creator_email=entity.creator.email if entity.creator else None,
|
149
97
|
updated_date=entity.modified_at,
|
150
98
|
message=message,
|
151
|
-
**kwargs,
|
152
99
|
)
|
153
100
|
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
101
|
+
|
102
|
+
def liminal_validator(
|
103
|
+
validator_level: ValidationSeverity = ValidationSeverity.LOW,
|
104
|
+
validator_name: str | None = None,
|
105
|
+
) -> Callable:
|
106
|
+
"""A decorator that validates a function that takes a Benchling entity as an argument and returns None.
|
107
|
+
|
108
|
+
Parameters:
|
109
|
+
validator_level: ValidationSeverity
|
110
|
+
The level of the validator.
|
111
|
+
validator_name: str | None
|
112
|
+
The name of the validator. Defaults to the pascalized name of the function.
|
113
|
+
"""
|
114
|
+
|
115
|
+
def decorator(func: Callable[[type["BenchlingBaseModel"]], None]) -> Callable:
|
116
|
+
"""Decorator that validates a function that takes a Benchling entity as an argument and returns None."""
|
117
|
+
sig = inspect.signature(func)
|
118
|
+
params = list(sig.parameters.values())
|
119
|
+
if not params or params[0].name != "self" or len(params) > 1:
|
120
|
+
raise TypeError(
|
121
|
+
"Validator must defined in a schema class, where the only argument to this validator must be 'self'."
|
122
|
+
)
|
123
|
+
|
124
|
+
if sig.return_annotation is not None:
|
125
|
+
raise TypeError("The return type must be None.")
|
126
|
+
|
127
|
+
nonlocal validator_name
|
128
|
+
if validator_name is None:
|
129
|
+
validator_name = pascalize(func.__name__)
|
130
|
+
|
131
|
+
@wraps(func)
|
132
|
+
def wrapper(self: type["BenchlingBaseModel"]) -> BenchlingValidatorReport:
|
133
|
+
"""Wrapper that runs the validator function and returns a BenchlingValidatorReport."""
|
134
|
+
try:
|
135
|
+
func(self)
|
136
|
+
except Exception as e:
|
137
|
+
return BenchlingValidatorReport.create_validation_report(
|
138
|
+
valid=False,
|
139
|
+
level=validator_level,
|
140
|
+
entity=self,
|
141
|
+
validator_name=validator_name,
|
142
|
+
message=str(e),
|
143
|
+
)
|
144
|
+
return BenchlingValidatorReport.create_validation_report(
|
145
|
+
valid=True,
|
146
|
+
level=validator_level,
|
147
|
+
entity=self,
|
148
|
+
validator_name=validator_name,
|
149
|
+
)
|
150
|
+
|
151
|
+
setattr(wrapper, "_is_liminal_validator", True)
|
152
|
+
return wrapper
|
153
|
+
|
154
|
+
return decorator
|