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
@@ -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: Any | None
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[Any] | None
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
- filtered_schemas = [cls.model_validate(schema) for schema in schemas_data]
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,
@@ -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
@@ -13,3 +13,11 @@ class BenchlingEntityType(StrEnum):
13
13
  ENTRY = "entry"
14
14
  MIXTURE = "mixture"
15
15
  MOLECULE = "molecule"
16
+
17
+ def is_sequence(self) -> bool:
18
+ return self in [
19
+ self.DNA_SEQUENCE,
20
+ self.RNA_SEQUENCE,
21
+ self.DNA_OLIGO,
22
+ self.RNA_OLIGO,
23
+ ]
@@ -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"
@@ -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
- all_raw_revisions.append(Revision.parse_from_file(file_path))
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 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.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: 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
- logger.warning(
59
- f"Schema prefix '{cls.__schema_properties__.prefix}' is already used by another subclass. Please ensure fieldsets=True in BenchlingConnection you are updating/creating this schema."
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.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")