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.
Files changed (34) hide show
  1. liminal/base/base_operation.py +4 -3
  2. liminal/base/base_validation_filters.py +15 -0
  3. liminal/base/name_template_parts.py +9 -0
  4. liminal/base/properties/base_field_properties.py +2 -2
  5. liminal/base/properties/base_name_template.py +83 -0
  6. liminal/base/properties/base_schema_properties.py +13 -1
  7. liminal/dropdowns/compare.py +8 -0
  8. liminal/dropdowns/operations.py +1 -1
  9. liminal/entity_schemas/api.py +18 -0
  10. liminal/entity_schemas/compare.py +62 -8
  11. liminal/entity_schemas/entity_schema_models.py +43 -0
  12. liminal/entity_schemas/generate_files.py +13 -11
  13. liminal/entity_schemas/operations.py +43 -18
  14. liminal/entity_schemas/tag_schema_models.py +146 -3
  15. liminal/entity_schemas/utils.py +15 -2
  16. liminal/enums/__init__.py +0 -1
  17. liminal/enums/benchling_entity_type.py +8 -0
  18. liminal/enums/name_template_part_type.py +12 -0
  19. liminal/external/__init__.py +11 -1
  20. liminal/migrate/revisions_timeline.py +2 -1
  21. liminal/orm/base_model.py +90 -29
  22. liminal/orm/name_template.py +39 -0
  23. liminal/orm/name_template_parts.py +96 -0
  24. liminal/orm/schema_properties.py +27 -1
  25. liminal/tests/conftest.py +18 -9
  26. liminal/tests/test_entity_schema_compare.py +61 -12
  27. liminal/utils.py +9 -0
  28. liminal/validation/__init__.py +84 -108
  29. liminal/{enums/benchling_report_level.py → validation/validation_severity.py} +2 -2
  30. {liminal_orm-1.1.4.dist-info → liminal_orm-2.0.1.dist-info}/METADATA +17 -20
  31. {liminal_orm-1.1.4.dist-info → liminal_orm-2.0.1.dist-info}/RECORD +34 -29
  32. {liminal_orm-1.1.4.dist-info → liminal_orm-2.0.1.dist-info}/LICENSE.md +0 -0
  33. {liminal_orm-1.1.4.dist-info → liminal_orm-2.0.1.dist-info}/WHEEL +0 -0
  34. {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
+ )
@@ -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() -> list[tuple[SchemaProperties, dict[str, Props]]]:
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] # type: ignore[type-abstract]
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] # type: ignore[type-abstract]
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] # type: ignore[type-abstract]
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][1].pop("string_field_req")
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][1]["extra_field"] = BaseFieldProperties(
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][1]["enum_field"].required = True
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][1]["string_field_req"].required = False
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][1]["list_dropdown_field"].is_multi = False
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][1]["enum_field"].is_multi = True
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][1][
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][1][
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][1]["string_field_req"].set_archived(True)
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][1]
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][1]["archived_field"].set_archived(False)
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)
@@ -1,13 +1,15 @@
1
- from abc import ABC, abstractmethod
1
+ import inspect
2
2
  from datetime import datetime
3
- from typing import TYPE_CHECKING, Any
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.enums import BenchlingReportLevel
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 BaseModelBenchling
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 : BenchlingReportLevel
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: BenchlingReportLevel
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 create_report(
63
+ def create_validation_report(
130
64
  cls,
131
65
  valid: bool,
132
- level: BenchlingReportLevel,
133
- entity: type["BaseModelBenchling"],
66
+ level: ValidationSeverity,
67
+ entity: type["BenchlingBaseModel"],
68
+ validator_name: str,
134
69
  message: str | None = None,
135
- **kwargs: Any,
136
- ) -> BenchlingValidatorReport:
137
- """Creates a BenchlingValidatorReport with the given parameters."""
138
- return BenchlingValidatorReport(
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=cls.__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
- def format_err(self, *msgs: str | None) -> str:
155
- """Creates a formatted error message from the given messages. The first message is prefixed with the class name and any instance variables.
156
- Ex: "BenchlingValidator(field_name=sample_code, field_value=123): The field value is invalid | The field value is too long"
157
- """
158
- ret_val = ""
159
- for ind, msg in enumerate(msgs):
160
- if ind == 0:
161
- if (msg is None) or (msg == ""):
162
- continue
163
- elif not msg.startswith(self._prefix()):
164
- ret_val += f"{self._prefix()} {msg}"
165
- else:
166
- ret_val += f"{msg}"
167
- elif ((msgs[0] is None) or (msgs[0] == "")) and (ind == 1):
168
- ret_val += f"{self._prefix()} {msg}"
169
- else:
170
- ret_val += f" | {msg}"
171
- return ret_val
172
-
173
- def truncate_msg(self, msg: Any, max_len: int = 150) -> str:
174
- """Shortens the given message to the given max length. If the message is longer than the max length, it is truncated and an ellipsis is added to the end."""
175
- msg = str(msg)
176
- if len(msg) > max_len:
177
- return f"{msg[:max_len]}..."
178
- return msg
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