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
@@ -6,6 +6,7 @@ import requests
|
|
6
6
|
from pydantic import BaseModel
|
7
7
|
|
8
8
|
from liminal.base.properties.base_field_properties import BaseFieldProperties
|
9
|
+
from liminal.base.properties.base_name_template import BaseNameTemplate
|
9
10
|
from liminal.base.properties.base_schema_properties import BaseSchemaProperties
|
10
11
|
from liminal.connection import BenchlingService
|
11
12
|
from liminal.dropdowns.utils import get_benchling_dropdown_summary_by_name
|
@@ -15,10 +16,14 @@ from liminal.enums import (
|
|
15
16
|
BenchlingFolderItemType,
|
16
17
|
BenchlingSequenceType,
|
17
18
|
)
|
19
|
+
from liminal.enums.name_template_part_type import NameTemplatePartType
|
18
20
|
from liminal.mappers import (
|
19
21
|
convert_entity_type_to_api_entity_type,
|
20
22
|
convert_field_type_to_api_field_type,
|
21
23
|
)
|
24
|
+
from liminal.orm.name_template_parts import (
|
25
|
+
NameTemplatePart,
|
26
|
+
)
|
22
27
|
from liminal.orm.schema_properties import MixtureSchemaConfig
|
23
28
|
|
24
29
|
|
@@ -32,6 +37,73 @@ class FieldRequiredLinkShortModel(BaseModel):
|
|
32
37
|
storableSchema: dict[str, Any] | None = None
|
33
38
|
|
34
39
|
|
40
|
+
class NameTemplatePartModel(BaseModel):
|
41
|
+
"""A pydantic model to define a part of a name template."""
|
42
|
+
|
43
|
+
type: NameTemplatePartType
|
44
|
+
fieldId: str | None = None
|
45
|
+
text: str | None = None
|
46
|
+
datetimeFormat: str | None = None
|
47
|
+
|
48
|
+
@classmethod
|
49
|
+
def from_name_template_part(
|
50
|
+
cls, part: NameTemplatePart, fields: list[TagSchemaFieldModel] | None = None
|
51
|
+
) -> NameTemplatePartModel:
|
52
|
+
data = part.model_dump()
|
53
|
+
field_id = None
|
54
|
+
if wh_field_name := data.get("wh_field_name"):
|
55
|
+
field = next((f for f in fields if f.systemName == wh_field_name), None)
|
56
|
+
if field is None:
|
57
|
+
raise ValueError(f"Field {wh_field_name} not found in fields")
|
58
|
+
field_id = field.id
|
59
|
+
if part.component_type == NameTemplatePartType.CHILD_ENTITY_LOT_NUMBER:
|
60
|
+
if not field.isParentLink:
|
61
|
+
raise ValueError(
|
62
|
+
f"Field {wh_field_name} is not a parent link field. The field for type {part.component_type} must be a parent link field."
|
63
|
+
)
|
64
|
+
return cls(
|
65
|
+
type=part.component_type,
|
66
|
+
fieldId=field_id,
|
67
|
+
text=data.get("value"),
|
68
|
+
)
|
69
|
+
|
70
|
+
def to_name_template_part(
|
71
|
+
self, fields: list[TagSchemaFieldModel] | None = None
|
72
|
+
) -> NameTemplatePart:
|
73
|
+
part_cls = NameTemplatePart.resolve_type(self.type)
|
74
|
+
if self.fieldId:
|
75
|
+
field = next((f for f in fields if f.apiId == self.fieldId), None)
|
76
|
+
if field is None:
|
77
|
+
raise ValueError(f"Field {self.fieldId} not found in fields")
|
78
|
+
return part_cls(wh_field_name=field.systemName, value=self.text)
|
79
|
+
return part_cls(value=self.text)
|
80
|
+
|
81
|
+
|
82
|
+
class TagSchemaConstraint(BaseModel):
|
83
|
+
"""
|
84
|
+
A class to define a constraint on an entity schema.
|
85
|
+
"""
|
86
|
+
|
87
|
+
areUniqueResiduesCaseSensitive: bool | None = None
|
88
|
+
fields: list[TagSchemaFieldModel] | None = None
|
89
|
+
uniqueCanonicalSmilers: bool | None = None
|
90
|
+
uniqueResidues: bool | None = None
|
91
|
+
|
92
|
+
@classmethod
|
93
|
+
def from_constraint_fields(
|
94
|
+
cls, constraint_fields: list[TagSchemaFieldModel], bases: bool
|
95
|
+
) -> TagSchemaConstraint:
|
96
|
+
"""
|
97
|
+
Generates a Constraint object from a set of constraint fields to create a constraint on a schema.
|
98
|
+
"""
|
99
|
+
return cls(
|
100
|
+
fields=constraint_fields,
|
101
|
+
uniqueResidues=bases,
|
102
|
+
uniqueCanonicalSmilers=False,
|
103
|
+
areUniqueResiduesCaseSensitive=False,
|
104
|
+
)
|
105
|
+
|
106
|
+
|
35
107
|
class UpdateTagSchemaModel(BaseModel):
|
36
108
|
"""A pydantic model to define the input for the internal tag schema update endpoint."""
|
37
109
|
|
@@ -46,6 +118,9 @@ class UpdateTagSchemaModel(BaseModel):
|
|
46
118
|
sequenceType: BenchlingSequenceType | None = None
|
47
119
|
shouldCreateAsOligo: bool | None = None
|
48
120
|
showResidues: bool | None = None
|
121
|
+
includeRegistryIdInChips: bool | None = None
|
122
|
+
useOrganizationCollectionAliasForDisplayLabel: bool | None = None
|
123
|
+
constraint: TagSchemaConstraint | None = None
|
49
124
|
|
50
125
|
|
51
126
|
class CreateTagSchemaFieldModel(BaseModel):
|
@@ -243,7 +318,7 @@ class TagSchemaModel(BaseModel):
|
|
243
318
|
authParentOption: str | None
|
244
319
|
batchSchemaId: str | None
|
245
320
|
childEntitySchemaSummaries: list[Any] | None
|
246
|
-
constraint:
|
321
|
+
constraint: TagSchemaConstraint | None
|
247
322
|
containableType: str | None
|
248
323
|
fields: list[TagSchemaFieldModel]
|
249
324
|
folderItemType: BenchlingFolderItemType
|
@@ -256,7 +331,7 @@ class TagSchemaModel(BaseModel):
|
|
256
331
|
mixtureSchemaConfig: MixtureSchemaConfig | None
|
257
332
|
name: str | None
|
258
333
|
nameTemplateFields: list[str] | None
|
259
|
-
nameTemplateParts: list[
|
334
|
+
nameTemplateParts: list[NameTemplatePartModel] | None
|
260
335
|
permissions: dict[str, bool] | None
|
261
336
|
prefix: str | None
|
262
337
|
registryId: str | None
|
@@ -299,7 +374,11 @@ class TagSchemaModel(BaseModel):
|
|
299
374
|
if len(filtered_schemas) == len(wh_schema_names):
|
300
375
|
break
|
301
376
|
else:
|
302
|
-
|
377
|
+
for schema in schemas_data:
|
378
|
+
try:
|
379
|
+
filtered_schemas.append(cls.model_validate(schema))
|
380
|
+
except Exception as e:
|
381
|
+
print(f"Error validating schema {schema['sqlIdentifier']}: {e}")
|
303
382
|
return filtered_schemas
|
304
383
|
|
305
384
|
@classmethod
|
@@ -333,6 +412,11 @@ class TagSchemaModel(BaseModel):
|
|
333
412
|
return field
|
334
413
|
raise ValueError(f"Field '{wh_field_name}' not found in schema")
|
335
414
|
|
415
|
+
def get_internal_name_template_parts(self) -> list[NameTemplatePart]:
|
416
|
+
return [
|
417
|
+
part.to_name_template_part(self.fields) for part in self.nameTemplateParts
|
418
|
+
]
|
419
|
+
|
336
420
|
def update_schema_props(self, update_diff: dict[str, Any]) -> TagSchemaModel:
|
337
421
|
"""Updates the schema properties given the schema properties defined in code."""
|
338
422
|
update_diff_names = list(update_diff.keys())
|
@@ -347,6 +431,46 @@ class TagSchemaModel(BaseModel):
|
|
347
431
|
self.labelingStrategies = [o.value for o in update_props.naming_strategies]
|
348
432
|
if "mixture_schema_config" in update_diff_names:
|
349
433
|
self.mixtureSchemaConfig = update_props.mixture_schema_config
|
434
|
+
if "use_registry_id_as_label" in update_diff_names:
|
435
|
+
self.useOrganizationCollectionAliasForDisplayLabel = (
|
436
|
+
update_props.use_registry_id_as_label
|
437
|
+
)
|
438
|
+
if "include_registry_id_in_chips" in update_diff_names:
|
439
|
+
self.includeRegistryIdInChips = update_props.include_registry_id_in_chips
|
440
|
+
|
441
|
+
if "constraint_fields" in update_diff_names:
|
442
|
+
if update_props.constraint_fields:
|
443
|
+
has_bases = False
|
444
|
+
if "bases" in update_props.constraint_fields:
|
445
|
+
has_bases = True
|
446
|
+
update_props.constraint_fields.discard("bases")
|
447
|
+
constraint_fields = [
|
448
|
+
f
|
449
|
+
for f in self.fields
|
450
|
+
if f.systemName in update_props.constraint_fields
|
451
|
+
]
|
452
|
+
self.constraint = TagSchemaConstraint.from_constraint_fields(
|
453
|
+
constraint_fields, has_bases
|
454
|
+
)
|
455
|
+
else:
|
456
|
+
self.constraint = None
|
457
|
+
|
458
|
+
if "constraint_fields" in update_diff_names:
|
459
|
+
if update_props.constraint_fields:
|
460
|
+
has_bases = False
|
461
|
+
if "bases" in update_props.constraint_fields:
|
462
|
+
has_bases = True
|
463
|
+
update_props.constraint_fields.discard("bases")
|
464
|
+
constraint_fields = [
|
465
|
+
f
|
466
|
+
for f in self.fields
|
467
|
+
if f.systemName in update_props.constraint_fields
|
468
|
+
]
|
469
|
+
self.constraint = TagSchemaConstraint.from_constraint_fields(
|
470
|
+
constraint_fields, has_bases
|
471
|
+
)
|
472
|
+
else:
|
473
|
+
self.constraint = None
|
350
474
|
|
351
475
|
self.prefix = (
|
352
476
|
update_props.prefix if "prefix" in update_diff_names else self.prefix
|
@@ -359,6 +483,25 @@ class TagSchemaModel(BaseModel):
|
|
359
483
|
self.name = update_props.name if "name" in update_diff_names else self.name
|
360
484
|
return self
|
361
485
|
|
486
|
+
def update_name_template(
|
487
|
+
self, update_name_template: BaseNameTemplate
|
488
|
+
) -> TagSchemaModel:
|
489
|
+
update_diff_names = update_name_template.model_dump(exclude_unset=True).keys()
|
490
|
+
self.nameTemplateParts = (
|
491
|
+
[
|
492
|
+
NameTemplatePartModel.from_name_template_part(part, self.fields)
|
493
|
+
for part in update_name_template.parts
|
494
|
+
]
|
495
|
+
if "parts" in update_diff_names
|
496
|
+
else self.nameTemplateParts
|
497
|
+
)
|
498
|
+
self.shouldOrderNamePartsBySequence = (
|
499
|
+
update_name_template.order_name_parts_by_sequence
|
500
|
+
if "order_name_parts_by_sequence" in update_diff_names
|
501
|
+
else self.shouldOrderNamePartsBySequence
|
502
|
+
)
|
503
|
+
return self
|
504
|
+
|
362
505
|
def update_field(
|
363
506
|
self,
|
364
507
|
benchling_service: BenchlingService,
|
liminal/entity_schemas/utils.py
CHANGED
@@ -9,6 +9,7 @@ from liminal.mappers import (
|
|
9
9
|
convert_api_entity_type_to_entity_type,
|
10
10
|
convert_api_field_type_to_field_type,
|
11
11
|
)
|
12
|
+
from liminal.orm.name_template import NameTemplate
|
12
13
|
from liminal.orm.schema_properties import MixtureSchemaConfig, SchemaProperties
|
13
14
|
|
14
15
|
|
@@ -16,7 +17,7 @@ def get_converted_tag_schemas(
|
|
16
17
|
benchling_service: BenchlingService,
|
17
18
|
include_archived: bool = False,
|
18
19
|
wh_schema_names: set[str] | None = None,
|
19
|
-
) -> list[tuple[SchemaProperties, dict[str, BaseFieldProperties]]]:
|
20
|
+
) -> list[tuple[SchemaProperties, NameTemplate, dict[str, BaseFieldProperties]]]:
|
20
21
|
"""This functions gets all Tag schemas from Benchling and converts them to our internal representation of a schema and its fields.
|
21
22
|
It parses the Tag Schema and creates SchemaProperties and a list of FieldProperties for each field in the schema.
|
22
23
|
If include_archived is True, it will include archived schemas and archived fields.
|
@@ -40,10 +41,15 @@ def convert_tag_schema_to_internal_schema(
|
|
40
41
|
tag_schema: TagSchemaModel,
|
41
42
|
dropdowns_map: dict[str, str],
|
42
43
|
include_archived_fields: bool = False,
|
43
|
-
) -> tuple[SchemaProperties, dict[str, BaseFieldProperties]]:
|
44
|
+
) -> tuple[SchemaProperties, NameTemplate, dict[str, BaseFieldProperties]]:
|
44
45
|
all_fields = tag_schema.allFields
|
45
46
|
if not include_archived_fields:
|
46
47
|
all_fields = [f for f in all_fields if not f.archiveRecord]
|
48
|
+
constraint_fields: set[str] | None = None
|
49
|
+
if tag_schema.constraint:
|
50
|
+
constraint_fields = set([f.systemName for f in tag_schema.constraint.fields])
|
51
|
+
if tag_schema.constraint.uniqueResidues:
|
52
|
+
constraint_fields.add("bases")
|
47
53
|
return (
|
48
54
|
SchemaProperties(
|
49
55
|
name=tag_schema.name,
|
@@ -63,7 +69,14 @@ def convert_tag_schema_to_internal_schema(
|
|
63
69
|
BenchlingNamingStrategy(strategy)
|
64
70
|
for strategy in tag_schema.labelingStrategies
|
65
71
|
),
|
72
|
+
constraint_fields=constraint_fields,
|
66
73
|
_archived=tag_schema.archiveRecord is not None,
|
74
|
+
use_registry_id_as_label=tag_schema.useOrganizationCollectionAliasForDisplayLabel,
|
75
|
+
include_registry_id_in_chips=tag_schema.includeRegistryIdInChips,
|
76
|
+
),
|
77
|
+
NameTemplate(
|
78
|
+
parts=tag_schema.get_internal_name_template_parts(),
|
79
|
+
order_name_parts_by_sequence=tag_schema.shouldOrderNamePartsBySequence,
|
67
80
|
),
|
68
81
|
{
|
69
82
|
f.systemName: convert_tag_schema_field_to_field_properties(f, dropdowns_map)
|
liminal/enums/__init__.py
CHANGED
@@ -4,5 +4,4 @@ from liminal.enums.benchling_entity_type import BenchlingEntityType
|
|
4
4
|
from liminal.enums.benchling_field_type import BenchlingFieldType
|
5
5
|
from liminal.enums.benchling_folder_item_type import BenchlingFolderItemType
|
6
6
|
from liminal.enums.benchling_naming_strategy import BenchlingNamingStrategy
|
7
|
-
from liminal.enums.benchling_report_level import BenchlingReportLevel
|
8
7
|
from liminal.enums.benchling_sequence_type import BenchlingSequenceType
|
@@ -0,0 +1,12 @@
|
|
1
|
+
from liminal.base.str_enum import StrEnum
|
2
|
+
|
3
|
+
|
4
|
+
class NameTemplatePartType(StrEnum):
|
5
|
+
SEPARATOR = "SEPARATOR"
|
6
|
+
TEXT = "TEXT"
|
7
|
+
CREATION_YEAR = "CREATED_AT_YEAR"
|
8
|
+
CREATION_DATE = "CREATED_AT_DATE"
|
9
|
+
FIELD = "FIELD"
|
10
|
+
REGISTRY_IDENTIFIER_NUMBER = "REGISTRY_IDENTIFIER_NUMBER"
|
11
|
+
PROJECT = "PROJECT"
|
12
|
+
CHILD_ENTITY_LOT_NUMBER = "CHILD_ENTITY_LOT_NUMBER"
|
liminal/external/__init__.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# flake8: noqa: F401
|
2
2
|
from liminal.base.base_operation import BaseOperation
|
3
3
|
from liminal.base.properties.base_field_properties import BaseFieldProperties
|
4
|
+
from liminal.base.properties.base_name_template import BaseNameTemplate
|
4
5
|
from liminal.base.properties.base_schema_properties import BaseSchemaProperties
|
5
6
|
from liminal.dropdowns.operations import (
|
6
7
|
ArchiveDropdown,
|
@@ -22,6 +23,7 @@ from liminal.entity_schemas.operations import (
|
|
22
23
|
UnarchiveEntitySchemaField,
|
23
24
|
UpdateEntitySchema,
|
24
25
|
UpdateEntitySchemaField,
|
26
|
+
UpdateEntitySchemaNameTemplate,
|
25
27
|
)
|
26
28
|
from liminal.enums import (
|
27
29
|
BenchlingAPIFieldType,
|
@@ -29,6 +31,14 @@ from liminal.enums import (
|
|
29
31
|
BenchlingFieldType,
|
30
32
|
BenchlingFolderItemType,
|
31
33
|
BenchlingNamingStrategy,
|
32
|
-
BenchlingReportLevel,
|
33
34
|
BenchlingSequenceType,
|
34
35
|
)
|
36
|
+
from liminal.orm.name_template_parts import (
|
37
|
+
CreationDatePart,
|
38
|
+
CreationYearPart,
|
39
|
+
FieldPart,
|
40
|
+
ProjectPart,
|
41
|
+
RegistryIdentifierNumberPart,
|
42
|
+
SeparatorPart,
|
43
|
+
TextPart,
|
44
|
+
)
|
@@ -30,7 +30,8 @@ class RevisionsTimeline:
|
|
30
30
|
)
|
31
31
|
all_raw_revisions: list[Revision] = []
|
32
32
|
for file_path in versions_dir_path.iterdir():
|
33
|
-
|
33
|
+
if file_path.is_file() and file_path.suffix == ".py":
|
34
|
+
all_raw_revisions.append(Revision.parse_from_file(file_path))
|
34
35
|
self.revisions_map = self.validate_revisions(all_raw_revisions)
|
35
36
|
|
36
37
|
def get_first_revision(self) -> Revision:
|
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.enums import BenchlingNamingStrategy
|
14
16
|
from liminal.orm.base import Base
|
15
17
|
from liminal.orm.base_tables.user import User
|
18
|
+
from liminal.orm.name_template import NameTemplate
|
19
|
+
from liminal.orm.name_template_parts import FieldPart
|
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.properties.base_name_template import BaseNameTemplate
|
8
|
+
from liminal.orm.name_template_parts import NameTemplateParts, SeparatorPart
|
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")
|