liminal-orm 1.1.3__py3-none-any.whl → 2.0.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.
Files changed (33) 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 +96 -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/schema_properties.py +27 -1
  24. liminal/tests/conftest.py +18 -9
  25. liminal/tests/test_entity_schema_compare.py +61 -12
  26. liminal/utils.py +9 -0
  27. liminal/validation/__init__.py +84 -108
  28. liminal/{enums/benchling_report_level.py → validation/validation_severity.py} +2 -2
  29. {liminal_orm-1.1.3.dist-info → liminal_orm-2.0.0.dist-info}/METADATA +17 -20
  30. {liminal_orm-1.1.3.dist-info → liminal_orm-2.0.0.dist-info}/RECORD +33 -29
  31. {liminal_orm-1.1.3.dist-info → liminal_orm-2.0.0.dist-info}/LICENSE.md +0 -0
  32. {liminal_orm-1.1.3.dist-info → liminal_orm-2.0.0.dist-info}/WHEEL +0 -0
  33. {liminal_orm-1.1.3.dist-info → liminal_orm-2.0.0.dist-info}/entry_points.txt +0 -0
liminal/orm/base_model.py CHANGED
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import inspect
3
4
  import logging
4
- from abc import abstractmethod
5
+ from types import FunctionType
5
6
  from typing import TYPE_CHECKING, Any, Generic, Type, TypeVar # noqa: UP035
6
7
 
7
8
  import pandas as pd # type: ignore
@@ -11,13 +12,13 @@ from sqlalchemy.orm import Query, RelationshipProperty, Session, relationship
11
12
  from sqlalchemy.orm.decl_api import declared_attr
12
13
 
13
14
  from liminal.base.base_validation_filters import BaseValidatorFilters
15
+ from liminal.base.name_template_parts import FieldPart
16
+ from liminal.enums import BenchlingNamingStrategy
14
17
  from liminal.orm.base import Base
15
18
  from liminal.orm.base_tables.user import User
19
+ from liminal.orm.name_template import NameTemplate
16
20
  from liminal.orm.schema_properties import SchemaProperties
17
- from liminal.validation import (
18
- BenchlingValidator,
19
- BenchlingValidatorReport,
20
- )
21
+ from liminal.validation import BenchlingValidatorReport
21
22
 
22
23
  if TYPE_CHECKING:
23
24
  from liminal.orm.column import Column
@@ -32,20 +33,24 @@ class BaseModel(Generic[T], Base):
32
33
 
33
34
  __abstract__ = True
34
35
  __schema_properties__: SchemaProperties
36
+ __name_template__: NameTemplate = NameTemplate(
37
+ parts=[], order_name_parts_by_sequence=False
38
+ )
35
39
 
36
40
  _existing_schema_warehouse_names: set[str] = set()
37
41
  _existing_schema_names: set[str] = set()
38
- _existing_schema_prefixes: set[str] = set()
42
+ _existing_schema_prefixes: list[str] = []
39
43
 
40
44
  def __init_subclass__(cls, **kwargs: Any):
41
45
  super().__init_subclass__(**kwargs)
46
+ warehouse_name = cls.__schema_properties__.warehouse_name
47
+ cls.__tablename__ = warehouse_name + "$raw"
42
48
  if "__schema_properties__" not in cls.__dict__ or not isinstance(
43
49
  cls.__schema_properties__, SchemaProperties
44
50
  ):
45
51
  raise NotImplementedError(
46
52
  f"{cls.__name__} must define 'schema_properties' class attribute"
47
53
  )
48
- warehouse_name = cls.__schema_properties__.warehouse_name
49
54
  if warehouse_name in cls._existing_schema_warehouse_names:
50
55
  raise ValueError(
51
56
  f"Warehouse name '{warehouse_name}' is already used by another subclass."
@@ -54,15 +59,48 @@ class BaseModel(Generic[T], Base):
54
59
  raise ValueError(
55
60
  f"Schema name '{cls.__schema_properties__.name}' is already used by another subclass."
56
61
  )
57
- if cls.__schema_properties__.prefix.lower() in cls._existing_schema_prefixes:
58
- raise ValueError(
59
- f"Schema prefix '{cls.__schema_properties__.prefix}' is already used by another subclass."
60
- )
61
-
62
+ column_wh_names = [
63
+ c[0] for c in cls.__dict__.items() if isinstance(c[1], SqlColumn)
64
+ ]
65
+ # Validate constraints
66
+ if cls.__schema_properties__.constraint_fields:
67
+ invalid_constraints = [
68
+ c
69
+ for c in cls.__schema_properties__.constraint_fields
70
+ if c not in (set(column_wh_names) | {"bases"})
71
+ ]
72
+ if invalid_constraints:
73
+ raise ValueError(
74
+ f"Constraints {', '.join(invalid_constraints)} are not fields on schema {cls.__schema_properties__.name}."
75
+ )
76
+ # Validate naming strategies
77
+ if any(
78
+ BenchlingNamingStrategy.is_template_based(strategy)
79
+ for strategy in cls.__schema_properties__.naming_strategies
80
+ ):
81
+ if not cls.__name_template__.parts:
82
+ raise ValueError(
83
+ "Name template must be set when using template-based naming strategies."
84
+ )
85
+ # Validate name template
86
+ if cls.__name_template__:
87
+ if not cls.__schema_properties__.entity_type.is_sequence():
88
+ if cls.__name_template__.order_name_parts_by_sequence is True:
89
+ raise ValueError(
90
+ "order_name_parts_by_sequence is only supported for sequence entities. Must be set to False if entity type is not a sequence."
91
+ )
92
+ if cls.__name_template__.parts:
93
+ field_parts = [
94
+ p for p in cls.__name_template__.parts if isinstance(p, FieldPart)
95
+ ]
96
+ for field_part in field_parts:
97
+ if field_part.wh_field_name not in column_wh_names:
98
+ raise ValueError(
99
+ f"Field part {field_part.wh_field_name} is not a column on schema {cls.__schema_properties__.name}."
100
+ )
62
101
  cls._existing_schema_warehouse_names.add(warehouse_name)
63
102
  cls._existing_schema_names.add(cls.__schema_properties__.name)
64
- cls._existing_schema_prefixes.add(cls.__schema_properties__.prefix.lower())
65
- cls.__tablename__ = warehouse_name + "$raw"
103
+ cls._existing_schema_prefixes.append(cls.__schema_properties__.prefix.lower())
66
104
 
67
105
  @declared_attr
68
106
  def creator_id(cls) -> SqlColumn:
@@ -130,13 +168,22 @@ class BaseModel(Generic[T], Base):
130
168
  return {c.name: c for c in columns}
131
169
 
132
170
  @classmethod
133
- def validate_model(cls) -> bool:
171
+ def validate_model_definition(cls) -> bool:
134
172
  model_columns = cls.get_columns_dict(exclude_base_columns=True)
135
173
  properties = {n: c.properties for n, c in model_columns.items()}
136
174
  errors = []
175
+ if (
176
+ cls._existing_schema_prefixes.count(
177
+ cls.__schema_properties__.prefix.lower()
178
+ )
179
+ > 1
180
+ ):
181
+ logger.warning(
182
+ f"{cls.__name__}: schema prefix '{cls.__schema_properties__.prefix}' is already used by another subclass. Please ensure fieldsets=True in BenchlingConnection you are updating/creating this schema."
183
+ )
137
184
  for wh_name, field in properties.items():
138
185
  try:
139
- field.validate_column(wh_name)
186
+ field.validate_column_definition(wh_name)
140
187
  except ValueError as e:
141
188
  errors.append(str(e))
142
189
  if errors:
@@ -224,25 +271,33 @@ class BaseModel(Generic[T], Base):
224
271
  """
225
272
  return session.query(cls)
226
273
 
227
- @abstractmethod
228
- def get_validators(self) -> list[BenchlingValidator]:
229
- """Abstract method that all subclasses must implement. Each subclass will have a differently defined list of
230
- validators to validate the entity. These validators will be run on each entity returned from the query.
231
- """
232
- raise NotImplementedError
274
+ @classmethod
275
+ def get_validators(cls) -> list[FunctionType]:
276
+ """Returns a list of all validators defined on the class. Validators are functions that are decorated with @validator."""
277
+ validators = []
278
+ for name, method in inspect.getmembers(cls, predicate=inspect.isfunction):
279
+ if hasattr(method, "_is_liminal_validator"):
280
+ validators.append(method)
281
+ return validators
233
282
 
234
283
  @classmethod
235
284
  def validate(
236
- cls, session: Session, base_filters: BaseValidatorFilters | None = None
285
+ cls,
286
+ session: Session,
287
+ base_filters: BaseValidatorFilters | None = None,
288
+ only_invalid: bool = False,
237
289
  ) -> list[BenchlingValidatorReport]:
238
290
  """Runs all validators for all entities returned from the query and returns a list of reports.
291
+ This returns a report for each entity, validator pair, regardless of whether the validation passed or failed.
239
292
 
240
293
  Parameters
241
294
  ----------
242
295
  session : Session
243
296
  Benchling database session.
244
- base_filters: BenchlingBaseValidatorFilters
297
+ base_filters: BaseValidatorFilters
245
298
  Filters to apply to the query.
299
+ only_invalid: bool
300
+ If True, only returns reports for entities that failed validation.
246
301
 
247
302
  Returns
248
303
  -------
@@ -254,15 +309,21 @@ class BaseModel(Generic[T], Base):
254
309
  cls.query(session), base_filters=base_filters
255
310
  ).all()
256
311
  logger.info(f"Validating {len(table)} entities for {cls.__name__}...")
312
+ validator_functions = cls.get_validators()
257
313
  for entity in table:
258
- for validator in entity.get_validators():
259
- report: BenchlingValidatorReport = validator.validate(entity)
314
+ for validator_func in validator_functions:
315
+ report: BenchlingValidatorReport = validator_func(entity)
316
+ if only_invalid and report.valid:
317
+ continue
260
318
  results.append(report)
261
319
  return results
262
320
 
263
321
  @classmethod
264
322
  def validate_to_df(
265
- cls, session: Session, base_filters: BaseValidatorFilters | None = None
323
+ cls,
324
+ session: Session,
325
+ base_filters: BaseValidatorFilters | None = None,
326
+ only_invalid: bool = False,
266
327
  ) -> pd.DataFrame:
267
328
  """Runs all validators for all entities returned from the query and returns reports as a pandas dataframe.
268
329
 
@@ -270,7 +331,7 @@ class BaseModel(Generic[T], Base):
270
331
  ----------
271
332
  session : Session
272
333
  Benchling database session.
273
- base_filters: BenchlingBaseValidatorFilters
334
+ base_filters: BaseValidatorFilters
274
335
  Filters to apply to the query.
275
336
 
276
337
  Returns
@@ -278,5 +339,5 @@ class BaseModel(Generic[T], Base):
278
339
  pd.Dataframe
279
340
  Dataframe of reports from running all validators on all entities returned from the query.
280
341
  """
281
- results = cls.validate(session, base_filters)
342
+ results = cls.validate(session, base_filters, only_invalid)
282
343
  return pd.DataFrame([r.model_dump() for r in results])
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import ConfigDict, field_validator
6
+
7
+ from liminal.base.name_template_parts import NameTemplateParts, SeparatorPart
8
+ from liminal.base.properties.base_name_template import BaseNameTemplate
9
+
10
+
11
+ class NameTemplate(BaseNameTemplate):
12
+ """
13
+ This class is the validated class that is public facing and inherits from the BaseNameTemplate class.
14
+ It has the same fields as the BaseNameTemplate class, but it is validated to ensure that the fields are valid.
15
+
16
+ Parameters
17
+ ----------
18
+ parts : list[NameTemplatePart] = []
19
+ The list of name template parts that make up the name template (order matters). Defaults to no parts, an empty list.
20
+ order_name_parts_by_sequence : bool = False
21
+ Whether to order the name parts by sequence. This can only be set to True for sequence enity types. If one or many part link fields are included in the name template,
22
+ list parts in the order they appear on the sequence map, sorted by start position and then end position.Defaults to False.
23
+ """
24
+
25
+ parts: list[NameTemplateParts] = []
26
+ order_name_parts_by_sequence: bool = False
27
+
28
+ @field_validator("parts")
29
+ def validate_parts(cls, v: list[NameTemplateParts]) -> list[NameTemplateParts]:
30
+ if len(v) > 0 and all(isinstance(part, SeparatorPart) for part in v):
31
+ raise ValueError(
32
+ "This name template will produce an empty name because it only includes separators. Please include at least one non-separator component."
33
+ )
34
+ return v
35
+
36
+ def __init__(self, **data: Any) -> None:
37
+ super().__init__(**data)
38
+
39
+ model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
@@ -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]
@@ -1,6 +1,7 @@
1
1
  import copy
2
2
  from unittest.mock import Mock, patch
3
3
 
4
+ from liminal.base.name_template_parts import TextPart
4
5
  from liminal.base.properties.base_field_properties import BaseFieldProperties
5
6
  from liminal.entity_schemas.compare import compare_entity_schemas
6
7
  from liminal.entity_schemas.operations import (
@@ -13,8 +14,10 @@ from liminal.entity_schemas.operations import (
13
14
  UnarchiveEntitySchemaField,
14
15
  UpdateEntitySchema,
15
16
  UpdateEntitySchemaField,
17
+ UpdateEntitySchemaNameTemplate,
16
18
  )
17
19
  from liminal.enums import BenchlingFieldType
20
+ from liminal.orm.name_template import NameTemplate
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)