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.
- liminal/base/base_operation.py +4 -3
- liminal/base/base_validation_filters.py +15 -0
- liminal/base/name_template_parts.py +96 -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/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.3.dist-info → liminal_orm-2.0.0.dist-info}/METADATA +17 -20
- {liminal_orm-1.1.3.dist-info → liminal_orm-2.0.0.dist-info}/RECORD +33 -29
- {liminal_orm-1.1.3.dist-info → liminal_orm-2.0.0.dist-info}/LICENSE.md +0 -0
- {liminal_orm-1.1.3.dist-info → liminal_orm-2.0.0.dist-info}/WHEEL +0 -0
- {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
|
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:
|
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
|
-
|
58
|
-
|
59
|
-
|
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.
|
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
|
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.
|
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
|
-
@
|
228
|
-
def get_validators(
|
229
|
-
"""
|
230
|
-
validators
|
231
|
-
|
232
|
-
|
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,
|
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:
|
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
|
259
|
-
report: BenchlingValidatorReport =
|
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,
|
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:
|
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")
|
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]
|
@@ -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][
|
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)
|