cognite-neat 0.127.18__py3-none-any.whl → 0.127.20__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.
@@ -5,6 +5,8 @@ from typing import Any, Literal
5
5
 
6
6
  from cognite.neat._data_model._constants import DEFAULT_MAX_LIST_SIZE, DEFAULT_MAX_LIST_SIZE_DIRECT_RELATIONS
7
7
  from cognite.neat._data_model.importers._table_importer.data_classes import (
8
+ CREATOR_KEY,
9
+ CREATOR_MARKER,
8
10
  DMSContainer,
9
11
  DMSEnum,
10
12
  DMSNode,
@@ -78,14 +80,34 @@ class DMSTableWriter:
78
80
  )
79
81
 
80
82
  ### Metadata Sheet ###
81
- @staticmethod
82
- def write_metadata(data_model: DataModelRequest) -> list[MetadataValue]:
83
- return [
83
+ @classmethod
84
+ def write_metadata(cls, data_model: DataModelRequest) -> list[MetadataValue]:
85
+ metadata = [
84
86
  MetadataValue(key=key, value=value)
85
87
  for key, value in data_model.model_dump(
86
- mode="json", by_alias=True, exclude_none=True, exclude={"views"}
88
+ mode="json", by_alias=True, exclude_none=True, exclude={"views", "description"}
87
89
  ).items()
88
90
  ]
91
+ if data_model.description:
92
+ description, creator = cls._serialize_description(data_model.description)
93
+ if description:
94
+ metadata.append(MetadataValue(key="description", value=description))
95
+ if creator:
96
+ metadata.append(MetadataValue(key=CREATOR_KEY, value=creator))
97
+ return metadata
98
+
99
+ @staticmethod
100
+ def _serialize_description(description: str | None) -> tuple[str | None, str | None]:
101
+ """DataModelRequest does not have a 'creator' field, this is a special addition that the Neat tables
102
+ format supports (and recommends using). If the data model was created using Neat, the suffix of the
103
+ description will be Creator: <creator>. This function extracts that information."""
104
+ if description is None:
105
+ return None, None
106
+ if CREATOR_MARKER not in description:
107
+ return description, None
108
+
109
+ description, creator = description.rsplit(CREATOR_MARKER, 1)
110
+ return description.rstrip(), creator.strip()
89
111
 
90
112
  ### Container Properties Sheet ###
91
113
 
@@ -24,6 +24,10 @@ from cognite.neat.v0.core._data_model.models.entities import (
24
24
  RawFilter,
25
25
  )
26
26
 
27
+ # This marker is used to identify creator in the description field.
28
+ CREATOR_MARKER = "Creator: "
29
+ CREATOR_KEY = "creator"
30
+
27
31
 
28
32
  def parse_entity_str(v: str) -> ParsedEntity:
29
33
  if isinstance(v, ParsedEntity):
@@ -9,6 +9,7 @@ from cognite.neat._data_model.models.dms import (
9
9
  Constraint,
10
10
  ConstraintAdapter,
11
11
  ContainerPropertyDefinition,
12
+ ContainerReference,
12
13
  ContainerRequest,
13
14
  DataModelRequest,
14
15
  Index,
@@ -17,17 +18,19 @@ from cognite.neat._data_model.models.dms import (
17
18
  RequestSchema,
18
19
  SpaceRequest,
19
20
  UniquenessConstraintDefinition,
21
+ ViewReference,
20
22
  ViewRequest,
21
23
  ViewRequestProperty,
22
24
  ViewRequestPropertyAdapter,
23
25
  )
26
+ from cognite.neat._data_model.models.dms._constants import DATA_MODEL_DESCRIPTION_MAX_LENGTH
24
27
  from cognite.neat._data_model.models.entities import ParsedEntity, parse_entity
25
28
  from cognite.neat._exceptions import DataModelImportException
26
29
  from cognite.neat._issues import ModelSyntaxError
27
30
  from cognite.neat._utils.text import humanize_collection
28
31
  from cognite.neat._utils.validation import ValidationContext, humanize_validation_error
29
32
 
30
- from .data_classes import DMSContainer, DMSEnum, DMSNode, DMSProperty, DMSView, TableDMS
33
+ from .data_classes import CREATOR_KEY, CREATOR_MARKER, DMSContainer, DMSEnum, DMSNode, DMSProperty, DMSView, TableDMS
31
34
  from .source import TableSource
32
35
 
33
36
  T_BaseModel = TypeVar("T_BaseModel", bound=BaseModel)
@@ -169,7 +172,9 @@ class DMSTableReader:
169
172
  space_request = self.read_space(self.default_space)
170
173
  node_types = self.read_nodes(tables.nodes)
171
174
  enum_collections = self.read_enum_collections(tables.enum)
172
- read = self.read_properties(tables.properties, enum_collections)
175
+ container_ref_by_entity = self.read_entity_by_container_ref(tables.containers)
176
+ view_ref_by_entity = self.read_entity_by_view_ref(tables.views)
177
+ read = self.read_properties(tables.properties, enum_collections, container_ref_by_entity, view_ref_by_entity)
173
178
  processed = self.process_properties(read)
174
179
  containers = self.read_containers(tables.containers, processed)
175
180
  views, valid_view_entities = self.read_views(tables.views, processed.view)
@@ -207,18 +212,50 @@ class DMSTableReader:
207
212
  }
208
213
  return enum_collections
209
214
 
215
+ def read_entity_by_container_ref(self, containers: list[DMSContainer]) -> dict[ContainerReference, ParsedEntity]:
216
+ entity_by_container_ref: dict[ContainerReference, ParsedEntity] = {}
217
+ for container in containers:
218
+ data = self._create_container_ref(container.container)
219
+ try:
220
+ container_ref = ContainerReference.model_validate(data)
221
+ except ValidationError:
222
+ # Error will be reported when reading the containers
223
+ continue
224
+ entity_by_container_ref[container_ref] = container.container
225
+ return entity_by_container_ref
226
+
227
+ def read_entity_by_view_ref(self, views: list[DMSView]) -> dict[ViewReference, ParsedEntity]:
228
+ entity_by_view_ref: dict[ViewReference, ParsedEntity] = {}
229
+ for view in views:
230
+ data = self._create_view_ref(view.view)
231
+ try:
232
+ view_ref = ViewReference.model_validate(data)
233
+ except ValidationError:
234
+ # Error will be reported when reading the views
235
+ continue
236
+ entity_by_view_ref[view_ref] = view.view
237
+ return entity_by_view_ref
238
+
210
239
  def read_properties(
211
- self, properties: list[DMSProperty], enum_collections: dict[str, dict[str, Any]]
240
+ self,
241
+ properties: list[DMSProperty],
242
+ enum_collections: dict[str, dict[str, Any]],
243
+ container_ref_by_entity: dict[ContainerReference, ParsedEntity],
244
+ view_ref_by_entity: dict[ViewReference, ParsedEntity],
212
245
  ) -> ReadProperties:
213
246
  read = ReadProperties()
247
+ view_entities = set(view_ref_by_entity.values())
248
+ container_entities = set(container_ref_by_entity.values())
214
249
  for row_no, prop in enumerate(properties):
215
- self._process_view_property(prop, read, row_no)
250
+ self._process_view_property(prop, read, row_no, view_ref_by_entity, view_entities)
216
251
  if prop.container is None or prop.container_property is None:
217
252
  # This is when the property is an edge or reverse direct relation property.
218
253
  continue
219
- self._process_container_property(prop, read, enum_collections, row_no)
220
- self._process_index(prop, read, row_no)
221
- self._process_constraint(prop, read, row_no)
254
+ self._process_container_property(
255
+ prop, read, enum_collections, row_no, container_ref_by_entity, container_entities
256
+ )
257
+ self._process_index(prop, read, row_no, container_ref_by_entity, container_entities)
258
+ self._process_constraint(prop, read, row_no, container_ref_by_entity, container_entities)
222
259
  return read
223
260
 
224
261
  def process_properties(self, read: ReadProperties) -> ProcessedProperties:
@@ -370,31 +407,91 @@ class DMSTableReader:
370
407
  constraints[container_entity][constraint_id] = constraint
371
408
  return constraints
372
409
 
373
- def _process_view_property(self, prop: DMSProperty, read: ReadProperties, row_no: int) -> None:
410
+ def _process_view_property(
411
+ self,
412
+ prop: DMSProperty,
413
+ read: ReadProperties,
414
+ row_no: int,
415
+ view_ref_by_entity: dict[ViewReference, ParsedEntity],
416
+ view_entities: set[ParsedEntity],
417
+ ) -> None:
374
418
  loc = (self.Sheets.properties, row_no)
375
419
  data = self.read_view_property(prop, loc)
376
420
  view_prop = self._validate_adapter(ViewRequestPropertyAdapter, data, loc)
377
- if view_prop is not None:
378
- read.view[(prop.view, prop.view_property)].append(
379
- # MyPy has a very strange complaint here. It complains that given type is not expected type,
380
- # even though they are exactly the same.
381
- ReadViewProperty(prop.container_property, row_no, view_prop) # type: ignore[arg-type]
421
+ if view_prop is None:
422
+ return None
423
+ if prop.view in view_entities:
424
+ read.view[(prop.view, prop.view_property)].append(ReadViewProperty(prop.view_property, row_no, view_prop))
425
+ return None
426
+ # The view entity was not found in the views table. This could either be because the view is missing,
427
+ # or because either the view entity in the Properties table and the View table are specified with/without
428
+ # default space/version inconsistently.
429
+ try:
430
+ view_ref = ViewReference.model_validate(self._create_view_ref(prop.view))
431
+ except ValidationError:
432
+ # Error will be reported when reading the views
433
+ return None
434
+ if view_ref in view_ref_by_entity:
435
+ view_ref_entity = view_ref_by_entity[view_ref]
436
+ read.view[(view_ref_entity, prop.view_property)].append(
437
+ ReadViewProperty(prop.view_property, row_no, view_prop)
438
+ )
439
+ else:
440
+ self.errors.append(
441
+ ModelSyntaxError(
442
+ message=(
443
+ f"In {self.source.location(loc)} the View '{prop.view!s}' "
444
+ f"was not found in the {self.Sheets.views!r} table."
445
+ )
446
+ )
382
447
  )
383
448
  return None
384
449
 
385
450
  def _process_container_property(
386
- self, prop: DMSProperty, read: ReadProperties, enum_collections: dict[str, dict[str, Any]], row_no: int
451
+ self,
452
+ prop: DMSProperty,
453
+ read: ReadProperties,
454
+ enum_collections: dict[str, dict[str, Any]],
455
+ row_no: int,
456
+ container_ref_by_entity: dict[ContainerReference, ParsedEntity],
457
+ container_entities: set[ParsedEntity],
387
458
  ) -> None:
388
459
  loc = (self.Sheets.properties, row_no)
389
460
  data = self.read_container_property(prop, enum_collections, loc=loc)
390
461
  container_prop = self._validate_obj(ContainerPropertyDefinition, data, loc)
391
- if container_prop is not None and prop.container and prop.container_property:
462
+ if container_prop is None:
463
+ return None
464
+ if not (prop.container and prop.container_property):
465
+ return None
466
+ if prop.container in container_entities:
392
467
  read.container[(prop.container, prop.container_property)].append(
393
468
  ReadContainerProperty(prop.container_property, row_no, container_prop)
394
469
  )
470
+ return None
471
+ # The container entity was not found in the containers table. This could either be because the container
472
+ # is missing, or because either the container entity in the Properties table and the Container table are
473
+ # specified with/without default space/version inconsistently.
474
+ try:
475
+ container_ref = ContainerReference.model_validate(self._create_container_ref(prop.container))
476
+ except ValidationError:
477
+ # Error will be reported when reading the containers
478
+ return None
479
+ if container_ref in container_ref_by_entity:
480
+ container_ref_entity = container_ref_by_entity[container_ref]
481
+ read.container[(container_ref_entity, prop.container_property)].append(
482
+ ReadContainerProperty(prop.container_property, row_no, container_prop)
483
+ )
484
+ # Container can be in CDF, this will be reported by a validator later.
395
485
  return None
396
486
 
397
- def _process_index(self, prop: DMSProperty, read: ReadProperties, row_no: int) -> None:
487
+ def _process_index(
488
+ self,
489
+ prop: DMSProperty,
490
+ read: ReadProperties,
491
+ row_no: int,
492
+ container_ref_by_entity: dict[ContainerReference, ParsedEntity],
493
+ container_entities: set[ParsedEntity],
494
+ ) -> None:
398
495
  if prop.index is None or prop.container_property is None or prop.container is None:
399
496
  return
400
497
 
@@ -405,11 +502,41 @@ class DMSTableReader:
405
502
  if created is None:
406
503
  continue
407
504
  order = self._read_order(index.properties, loc)
408
- read.indices[(prop.container, index.suffix)].append(
409
- ReadIndex(
410
- prop_id=prop.container_property, order=order, row_no=row_no, index_id=index.suffix, index=created
505
+
506
+ if prop.container in container_entities:
507
+ read.indices[(prop.container, index.suffix)].append(
508
+ ReadIndex(
509
+ prop_id=prop.container_property,
510
+ order=order,
511
+ row_no=row_no,
512
+ index_id=index.suffix,
513
+ index=created,
514
+ )
411
515
  )
412
- )
516
+ continue
517
+ # The container entity was not found in the containers table. This could either be because the
518
+ # container is missing, or because either the container entity in the Properties table and the
519
+ # Container table are specified with/without default space/version inconsistently.
520
+
521
+ try:
522
+ container_ref = ContainerReference.model_validate(self._create_container_ref(prop.container))
523
+ except ValidationError:
524
+ # Error will be reported when reading the containers
525
+ continue
526
+ if container_ref in container_ref_by_entity:
527
+ container_ref_entity = container_ref_by_entity[container_ref]
528
+ read.indices[(container_ref_entity, index.suffix)].append(
529
+ ReadIndex(
530
+ prop_id=prop.container_property,
531
+ order=order,
532
+ row_no=row_no,
533
+ index_id=index.suffix,
534
+ index=created,
535
+ )
536
+ )
537
+ else:
538
+ # Error is reported when reading the property.
539
+ ...
413
540
 
414
541
  def _read_order(self, properties: dict[str, Any], loc: tuple[str | int, ...]) -> int | None:
415
542
  if "order" not in properties:
@@ -433,7 +560,14 @@ class DMSTableReader:
433
560
  **index.properties,
434
561
  }
435
562
 
436
- def _process_constraint(self, prop: DMSProperty, read: ReadProperties, row_no: int) -> None:
563
+ def _process_constraint(
564
+ self,
565
+ prop: DMSProperty,
566
+ read: ReadProperties,
567
+ row_no: int,
568
+ container_ref_by_entity: dict[ContainerReference, ParsedEntity],
569
+ container_entities: set[ParsedEntity],
570
+ ) -> None:
437
571
  if prop.constraint is None or prop.container_property is None or prop.container is None:
438
572
  return
439
573
  loc = (self.Sheets.properties, row_no, self.PropertyColumns.constraint)
@@ -443,15 +577,40 @@ class DMSTableReader:
443
577
  if created is None:
444
578
  continue
445
579
  order = self._read_order(constraint.properties, loc)
446
- read.constraints[(prop.container, constraint.suffix)].append(
447
- ReadConstraint(
448
- prop_id=prop.container_property,
449
- order=order,
450
- constraint_id=constraint.suffix,
451
- row_no=row_no,
452
- constraint=created,
580
+
581
+ if prop.container in container_entities:
582
+ read.constraints[(prop.container, constraint.suffix)].append(
583
+ ReadConstraint(
584
+ prop_id=prop.container_property,
585
+ order=order,
586
+ constraint_id=constraint.suffix,
587
+ row_no=row_no,
588
+ constraint=created,
589
+ )
453
590
  )
454
- )
591
+ continue
592
+ # The container entity was not found in the containers table. This could either be because the
593
+ # container is missing, or because either the container entity in the Properties table and the
594
+ # Container table are specified with/without default space/version inconsistently.
595
+ try:
596
+ container_ref = ContainerReference.model_validate(self._create_container_ref(prop.container))
597
+ except ValidationError:
598
+ # Error will be reported when reading the containers
599
+ continue
600
+ if container_ref in container_ref_by_entity:
601
+ container_ref_entity = container_ref_by_entity[container_ref]
602
+ read.constraints[(container_ref_entity, constraint.suffix)].append(
603
+ ReadConstraint(
604
+ prop_id=prop.container_property,
605
+ order=order,
606
+ constraint_id=constraint.suffix,
607
+ row_no=row_no,
608
+ constraint=created,
609
+ )
610
+ )
611
+ else:
612
+ # Error is reported when reading the property.
613
+ ...
455
614
 
456
615
  @staticmethod
457
616
  def read_property_constraint(constraint: ParsedEntity, prop_id: str) -> dict[str, Any]:
@@ -733,16 +892,45 @@ class DMSTableReader:
733
892
  return views_requests, set(rows_by_seen.keys())
734
893
 
735
894
  def read_data_model(self, tables: TableDMS, valid_view_entities: set[ParsedEntity]) -> DataModelRequest:
736
- data = {
895
+ data: dict[str, Any] = {
737
896
  **{meta.key: meta.value for meta in tables.metadata},
738
897
  "views": [self._create_view_ref(view.view) for view in tables.views if view.view in valid_view_entities],
739
898
  }
899
+ if description := self._create_description_field(data):
900
+ data["description"] = description
740
901
  model = self._validate_obj(DataModelRequest, data, (self.Sheets.metadata,), field_name="value")
741
902
  if model is None:
742
903
  # This is the last step, so we can raise the error here.
743
904
  raise DataModelImportException(self.errors) from None
744
905
  return model
745
906
 
907
+ def _create_description_field(self, data: dict[str, Any]) -> str | None:
908
+ """DataModelRequest does not have a 'creator' field, this is a special addition that the Neat tables
909
+ format supports (and recommends using). To keep it, Neat adds it to the suffix of the description field.
910
+ """
911
+ if CREATOR_KEY not in data and CREATOR_KEY.title() not in data:
912
+ return None
913
+ creator_val = data.pop(CREATOR_KEY, data.pop(CREATOR_KEY.title(), None))
914
+
915
+ if not creator_val:
916
+ return None
917
+
918
+ creator = str(creator_val)
919
+ # We do a split/join to clean up any spaces around commas. Ensuring that we have a consistent
920
+ # canonical format.
921
+ cleaned_creator = ", ".join(item.strip() for item in creator.split(","))
922
+ if not cleaned_creator:
923
+ return None
924
+ suffix = f"{CREATOR_MARKER}{cleaned_creator}"
925
+ description = data.get("description", "")
926
+ if len(description) + len(suffix) > DATA_MODEL_DESCRIPTION_MAX_LENGTH:
927
+ description = description[: DATA_MODEL_DESCRIPTION_MAX_LENGTH - len(suffix) - 4] + "..."
928
+ if description:
929
+ description = f"{description} {suffix}"
930
+ else:
931
+ description = suffix
932
+ return description
933
+
746
934
  def _parse_entity(self, entity: str, loc: tuple[str | int, ...]) -> ParsedEntity | None:
747
935
  try:
748
936
  parsed = parse_entity(entity)
@@ -58,6 +58,27 @@ from ._references import (
58
58
  ViewReference,
59
59
  )
60
60
  from ._schema import RequestSchema
61
+ from ._view_filter import (
62
+ AVAILABLE_FILTERS,
63
+ AndFilter,
64
+ ContainsAllFilterData,
65
+ ContainsAnyFilterData,
66
+ EqualsFilterData,
67
+ ExistsFilterData,
68
+ Filter,
69
+ FilterAdapter,
70
+ FilterDataDefinition,
71
+ HasDataFilter,
72
+ InFilterData,
73
+ InstanceReferencesFilterData,
74
+ MatchAllFilterData,
75
+ NestedFilterData,
76
+ NotFilter,
77
+ OrFilter,
78
+ OverlapsFilterData,
79
+ PrefixFilterData,
80
+ RangeFilterData,
81
+ )
61
82
  from ._view_property import (
62
83
  ConnectionPropertyDefinition,
63
84
  ConstraintOrIndexState,
@@ -81,8 +102,10 @@ from ._views import (
81
102
  )
82
103
 
83
104
  __all__ = [
105
+ "AVAILABLE_FILTERS",
84
106
  "DMS_DATA_TYPES",
85
107
  "APIResource",
108
+ "AndFilter",
86
109
  "BaseModelObject",
87
110
  "BooleanProperty",
88
111
  "BtreeIndex",
@@ -99,6 +122,8 @@ __all__ = [
99
122
  "ContainerReference",
100
123
  "ContainerRequest",
101
124
  "ContainerResponse",
125
+ "ContainsAllFilterData",
126
+ "ContainsAnyFilterData",
102
127
  "DataModelBody",
103
128
  "DataModelReference",
104
129
  "DataModelRequest",
@@ -110,23 +135,38 @@ __all__ = [
110
135
  "DirectNodeRelation",
111
136
  "EnumProperty",
112
137
  "EnumValue",
138
+ "EqualsFilterData",
139
+ "ExistsFilterData",
113
140
  "FileCDFExternalIdReference",
141
+ "Filter",
142
+ "FilterAdapter",
143
+ "FilterDataDefinition",
114
144
  "Float32Property",
115
145
  "Float64Property",
116
146
  "FloatProperty",
147
+ "HasDataFilter",
148
+ "InFilterData",
117
149
  "Index",
118
150
  "IndexAdapter",
119
151
  "IndexDefinition",
152
+ "InstanceReferencesFilterData",
120
153
  "Int32Property",
121
154
  "Int64Property",
122
155
  "InvertedIndex",
123
156
  "JSONProperty",
124
157
  "ListablePropertyTypeDefinition",
158
+ "MatchAllFilterData",
125
159
  "MultiEdgeProperty",
126
160
  "MultiReverseDirectRelationPropertyRequest",
127
161
  "MultiReverseDirectRelationPropertyResponse",
162
+ "NestedFilterData",
128
163
  "NodeReference",
164
+ "NotFilter",
165
+ "OrFilter",
166
+ "OverlapsFilterData",
167
+ "PrefixFilterData",
129
168
  "PropertyTypeDefinition",
169
+ "RangeFilterData",
130
170
  "RequestSchema",
131
171
  "RequiresConstraintDefinition",
132
172
  "Resource",
@@ -4,6 +4,7 @@ CONTAINER_AND_VIEW_PROPERTIES_IDENTIFIER_PATTERN = r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0
4
4
  INSTANCE_ID_PATTERN = r"^[^\x00]{1,256}$"
5
5
  ENUM_VALUE_IDENTIFIER_PATTERN = r"^[_A-Za-z][_0-9A-Za-z]{0,127}$"
6
6
  DM_VERSION_PATTERN = r"^[a-zA-Z0-9]([.a-zA-Z0-9_-]{0,41}[a-zA-Z0-9])?$"
7
+ DATA_MODEL_DESCRIPTION_MAX_LENGTH = 1024
7
8
  FORBIDDEN_ENUM_VALUES = frozenset({"true", "false", "null"})
8
9
  FORBIDDEN_SPACES = frozenset(["space", "cdf", "dms", "pg3", "shared", "system", "node", "edge"])
9
10
  FORBIDDEN_CONTAINER_AND_VIEW_EXTERNAL_IDS = frozenset(
@@ -6,6 +6,7 @@ from pydantic_core.core_schema import FieldSerializationInfo
6
6
 
7
7
  from ._base import APIResource, Resource, WriteableResource
8
8
  from ._constants import (
9
+ DATA_MODEL_DESCRIPTION_MAX_LENGTH,
9
10
  DM_EXTERNAL_ID_PATTERN,
10
11
  DM_VERSION_PATTERN,
11
12
  SPACE_FORMAT_PATTERN,
@@ -46,7 +47,7 @@ class DataModel(Resource, APIResource[DataModelReference], ABC):
46
47
  description: str | None = Field(
47
48
  default=None,
48
49
  description="Description of the data model.",
49
- max_length=1024,
50
+ max_length=DATA_MODEL_DESCRIPTION_MAX_LENGTH,
50
51
  )
51
52
  # The API supports View here, but in Neat we will only use ViewReference
52
53
  views: list[ViewReference] | None = Field(
@@ -0,0 +1,282 @@
1
+ from abc import ABC
2
+ from typing import Annotated, Any, Literal, TypeAlias, get_args
3
+
4
+ from pydantic import BeforeValidator, Field, JsonValue, TypeAdapter, model_serializer
5
+ from pydantic_core.core_schema import FieldSerializationInfo
6
+
7
+ from cognite.neat._utils.text import humanize_collection
8
+ from cognite.neat._utils.useful_types import BaseModelObject
9
+
10
+ from ._references import ContainerReference, NodeReference, ViewReference
11
+
12
+ # Base classes and helpers
13
+
14
+
15
+ class Parameter(BaseModelObject):
16
+ parameter: str
17
+
18
+
19
+ class FilterDataDefinition(BaseModelObject, ABC):
20
+ """Base class for filter data models."""
21
+
22
+ # This is an internal field used for discriminating between filter types. It is not part of the actual
23
+ # data sent to or received from the API. See the _move_filter_key function for more details.
24
+ filter_type: str = Field(..., exclude=True)
25
+
26
+
27
+ class PropertyReference(FilterDataDefinition, ABC):
28
+ """Represents the property path in filters."""
29
+
30
+ property: list[str] = Field(..., min_length=2, max_length=3)
31
+
32
+
33
+ ## Leaf filters that follows the standard pattern
34
+
35
+
36
+ class EqualsFilterData(PropertyReference):
37
+ filter_type: Literal["equals"] = Field("equals", exclude=True)
38
+ value: JsonValue | PropertyReference
39
+
40
+
41
+ class InFilterData(PropertyReference):
42
+ filter_type: Literal["in"] = Field("in", exclude=True)
43
+ values: list[JsonValue] | PropertyReference
44
+
45
+
46
+ class RangeFilterData(PropertyReference):
47
+ filter_type: Literal["range"] = Field("range", exclude=True)
48
+ gt: str | int | float | PropertyReference | None = None
49
+ gte: str | int | float | PropertyReference | None = None
50
+ lt: str | int | float | PropertyReference | None = None
51
+ lte: str | int | float | PropertyReference | None = None
52
+
53
+
54
+ class PrefixFilterData(PropertyReference):
55
+ filter_type: Literal["prefix"] = Field("prefix", exclude=True)
56
+ value: str | Parameter
57
+
58
+
59
+ class ExistsFilterData(PropertyReference):
60
+ filter_type: Literal["exists"] = Field("exists", exclude=True)
61
+
62
+
63
+ class ContainsAnyFilterData(PropertyReference):
64
+ filter_type: Literal["containsAny"] = Field("containsAny", exclude=True)
65
+ values: list[JsonValue] | PropertyReference
66
+
67
+
68
+ class ContainsAllFilterData(PropertyReference):
69
+ filter_type: Literal["containsAll"] = Field("containsAll", exclude=True)
70
+ values: list[JsonValue] | PropertyReference
71
+
72
+
73
+ class MatchAllFilterData(FilterDataDefinition):
74
+ filter_type: Literal["matchAll"] = Field("matchAll", exclude=True)
75
+
76
+
77
+ class NestedFilterData(PropertyReference):
78
+ filter_type: Literal["nested"] = Field("nested", exclude=True)
79
+ scope: list[str] = Field(..., min_length=1, max_length=3)
80
+ filter: "Filter"
81
+
82
+
83
+ class OverlapsFilterData(PropertyReference):
84
+ filter_type: Literal["overlaps"] = Field("overlaps", exclude=True)
85
+ start_property: list[str] = Field(..., min_length=1, max_length=3)
86
+ end_property: list[str] = Field(..., min_length=1, max_length=3)
87
+ gt: str | int | float | PropertyReference | None = None
88
+ gte: str | int | float | PropertyReference | None = None
89
+ lt: str | int | float | PropertyReference | None = None
90
+ lte: str | int | float | PropertyReference | None = None
91
+
92
+
93
+ class ListFilterDataDefinition(FilterDataDefinition, ABC):
94
+ """Base class for filters that operate on lists of values."""
95
+
96
+ data: list[Any]
97
+
98
+
99
+ ## Leaf filters with custom serialization logic due to creativity in the API design
100
+
101
+
102
+ class HasDataFilter(ListFilterDataDefinition):
103
+ filter_type: Literal["hasData"] = Field("hasData", exclude=True)
104
+ data: list[ViewReference | ContainerReference]
105
+
106
+ # MyPy complains about thet signature of the method here, even though its compatible with the pydantic source code.
107
+ # And tests are passing fine.
108
+ @model_serializer(mode="plain") # type: ignore[type-var]
109
+ def serialize_model(self, info: FieldSerializationInfo) -> list[dict[str, Any]]:
110
+ output: list[dict[str, Any]] = []
111
+ for item in self.data:
112
+ item_dict = item.model_dump(**vars(info))
113
+ if isinstance(item, ViewReference):
114
+ item_dict["type"] = "view"
115
+ elif isinstance(item, ContainerReference):
116
+ item_dict["type"] = "container"
117
+ output.append(item_dict)
118
+ return output
119
+
120
+
121
+ class InstanceReferencesFilterData(ListFilterDataDefinition):
122
+ filter_type: Literal["instanceReferences"] = Field("instanceReferences", exclude=True)
123
+ data: list[NodeReference]
124
+
125
+ # MyPy complains about thet signature of the method here, even though its compatible with the pydantic source code.
126
+ # And tests are passing fine.
127
+ @model_serializer(mode="plain") # type: ignore[type-var]
128
+ def serialize_model(self, info: FieldSerializationInfo) -> list[dict[str, Any]]:
129
+ return [item.model_dump(**vars(info)) for item in self.data]
130
+
131
+
132
+ ## Logical filters combining other filters
133
+
134
+
135
+ class AndFilter(ListFilterDataDefinition):
136
+ filter_type: Literal["and"] = Field("and", exclude=True)
137
+ data: "list[Filter]"
138
+
139
+ # MyPy complains about thet signature of the method here, even though its compatible with the pydantic source code.
140
+ # And tests are passing fine.
141
+ @model_serializer(mode="plain") # type: ignore[type-var]
142
+ def serialize_model(self, info: FieldSerializationInfo) -> list[dict[str, Any]]:
143
+ return [FilterAdapter.dump_python(item, **vars(info)) for item in self.data]
144
+
145
+
146
+ class OrFilter(ListFilterDataDefinition):
147
+ filter_type: Literal["or"] = Field("or", exclude=True)
148
+ data: "list[Filter]"
149
+
150
+ # MyPy complains about thet signature of the method here, even though its compatible with the pydantic source code.
151
+ # And tests are passing fine.
152
+ @model_serializer(mode="plain") # type: ignore[type-var]
153
+ def serialize_model(self, info: FieldSerializationInfo) -> list[dict[str, Any]]:
154
+ return [FilterAdapter.dump_python(item, **vars(info)) for item in self.data]
155
+
156
+
157
+ class NotFilter(FilterDataDefinition):
158
+ filter_type: Literal["not"] = Field("not", exclude=True)
159
+ data: "Filter"
160
+
161
+ # MyPy complains about thet signature of the method here, even though its compatible with the pydantic source code.
162
+ # And tests are passing fine.
163
+ @model_serializer(mode="plain") # type: ignore[type-var]
164
+ def serialize_model(self, info: FieldSerializationInfo) -> dict[str, Any]:
165
+ return FilterAdapter.dump_python(self.data, **vars(info))
166
+
167
+
168
+ FilterData = Annotated[
169
+ EqualsFilterData
170
+ | PrefixFilterData
171
+ | InFilterData
172
+ | RangeFilterData
173
+ | ExistsFilterData
174
+ | ContainsAnyFilterData
175
+ | ContainsAllFilterData
176
+ | MatchAllFilterData
177
+ | NestedFilterData
178
+ | OverlapsFilterData
179
+ | HasDataFilter
180
+ | InstanceReferencesFilterData
181
+ | AndFilter
182
+ | OrFilter
183
+ | NotFilter,
184
+ Field(discriminator="filter_type"),
185
+ ]
186
+
187
+
188
+ FilterTypes: TypeAlias = Literal[
189
+ "equals",
190
+ "prefix",
191
+ "in",
192
+ "range",
193
+ "exists",
194
+ "containsAny",
195
+ "containsAll",
196
+ "matchAll",
197
+ "nested",
198
+ "overlaps",
199
+ "hasData",
200
+ "instanceReferences",
201
+ "and",
202
+ "or",
203
+ "not",
204
+ ]
205
+
206
+ AVAILABLE_FILTERS: frozenset[str] = frozenset(get_args(FilterTypes))
207
+
208
+
209
+ def _move_filter_key(value: Any) -> Any:
210
+ """The DMS API filters have an unusual structure.
211
+
212
+ It has the filter type as the key of the outer dict, and then the actual filter data as the value, e.g.,
213
+ {
214
+ "equals": {
215
+ "property": [...],
216
+ "value": ...
217
+ }
218
+ }
219
+ We could have modeled it that way with Pydantic, and had an union of pydantic models of all possible filter types.
220
+ However, validating union types in Pydantic without a discriminator leads to poor error messages. If the filter
221
+ data does not comply with any of the union types, Pydantic will give one error message per union type. For exampl,
222
+ if the user writes
223
+ {
224
+ "equals": {
225
+ "property": "my_property" # Should be a list,
226
+ "value": 'my_value'
227
+ }
228
+ }
229
+ Pydantic will give 15 error messages, one for each filter type in the union, saying that the data does not
230
+ comply with that filter type. This is not very user-friendly.
231
+
232
+ Instead, we introduce an internal field "filter_type" inside the filter data models, and use that as a
233
+ discriminator. This will enable the validation to be two steps. First, we validate the outer key and
234
+ that it is a known filter type. Then, we move that key inside the filter data as the "filter_type" field, and
235
+ validate the filter data against the correct model based on that discriminator.
236
+
237
+
238
+ This function transforms the data from the outer-key format to the inner-key format. For example, it transforms
239
+ the equals filter form above into
240
+
241
+ {
242
+ "equals": {
243
+ "property": [...],
244
+ "value": ...,
245
+ "filterType": "equals"
246
+ }
247
+ }
248
+ """
249
+ if not isinstance(value, dict):
250
+ return value
251
+ if len(value) != 1:
252
+ raise ValueError("Filter data must have exactly one key.")
253
+ if "filterType" in value:
254
+ # Already in the correct format
255
+ return value
256
+ key, data = next(iter(value.items()))
257
+ if key not in AVAILABLE_FILTERS:
258
+ raise ValueError(
259
+ f"Unknown filter type: {key!r}. Available filter types: {humanize_collection(AVAILABLE_FILTERS)}."
260
+ )
261
+ if isinstance(data, dict) and key == "not":
262
+ # Not is a recursive filter, so we need to move the filter key inside its data as well
263
+ output = _move_filter_key(data.copy())
264
+ return {key: {"filterType": key, "data": output}}
265
+ elif isinstance(data, dict):
266
+ output = data.copy()
267
+ output["filterType"] = key
268
+ return {key: output}
269
+ elif isinstance(data, list) and key in {"and", "or"}:
270
+ # And and Or are recursive filters, so we need to move the filter key inside each of their data items as well
271
+ return {key: {"filterType": key, "data": [_move_filter_key(item) for item in data]}}
272
+ elif isinstance(data, list):
273
+ # Leaf list filters, hasData and instanceReferences
274
+ return {key: {"filterType": key, "data": data}}
275
+ else:
276
+ # Let the regular validation handle it (possible not an issue)
277
+ return value
278
+
279
+
280
+ Filter = Annotated[dict[FilterTypes, FilterData], BeforeValidator(_move_filter_key)]
281
+
282
+ FilterAdapter: TypeAdapter[Filter] = TypeAdapter(Filter)
@@ -2,7 +2,7 @@ import re
2
2
  from abc import ABC
3
3
  from typing import Any, Literal, TypeVar
4
4
 
5
- from pydantic import Field, JsonValue, field_serializer, field_validator, model_validator
5
+ from pydantic import Field, field_serializer, field_validator, model_validator
6
6
  from pydantic_core.core_schema import FieldSerializationInfo
7
7
 
8
8
  from cognite.neat._utils.text import humanize_collection
@@ -18,6 +18,7 @@ from ._constants import (
18
18
  SPACE_FORMAT_PATTERN,
19
19
  )
20
20
  from ._references import ContainerReference, NodeReference, ViewReference
21
+ from ._view_filter import Filter
21
22
  from ._view_property import (
22
23
  EdgeProperty,
23
24
  ViewCorePropertyResponse,
@@ -56,9 +57,9 @@ class View(Resource, APIResource[ViewReference], ABC):
56
57
  description="Description of the view.",
57
58
  max_length=1024,
58
59
  )
59
- filter: dict[str, JsonValue] | None = Field(
60
+ filter: Filter | None = Field(
60
61
  default=None,
61
- description="A filter Domain Specific Language (DSL) used to create advanced filter queries.",
62
+ description="A filter Domain Specific Language (DSL) used to select which instances the view should include.",
62
63
  )
63
64
  implements: list[ViewReference] | None = Field(
64
65
  default=None,
cognite/neat/_version.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "0.127.18"
1
+ __version__ = "0.127.20"
2
2
  __engine__ = "^2.0.4"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cognite-neat
3
- Version: 0.127.18
3
+ Version: 0.127.20
4
4
  Summary: Knowledge graph transformation
5
5
  Project-URL: Documentation, https://cognite-neat.readthedocs-hosted.com/
6
6
  Project-URL: Homepage, https://cognite-neat.readthedocs-hosted.com/
@@ -1,7 +1,7 @@
1
1
  cognite/neat/__init__.py,sha256=Lo4DbjDOwnhCYUoAgPp5RG1fDdF7OlnomalTe7n1ydw,211
2
2
  cognite/neat/_exceptions.py,sha256=ox-5hXpee4UJlPE7HpuEHV2C96aLbLKo-BhPDoOAzhA,1650
3
3
  cognite/neat/_issues.py,sha256=wH1mnkrpBsHUkQMGUHFLUIQWQlfJ_qMfdF7q0d9wNhY,1871
4
- cognite/neat/_version.py,sha256=RpJs0sO0_LsEewFOvQbwxwfMhPN1BfsPoPZai6GswuA,47
4
+ cognite/neat/_version.py,sha256=x1it8CkOgLQbQkRRhDB46FfLXgM0aI8us2ZRTHFumD4,47
5
5
  cognite/neat/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  cognite/neat/v1.py,sha256=owqW5Mml2DSZx1AvPvwNRTBngfhBNrQ6EH-7CKL7Jp0,61
7
7
  cognite/neat/_client/__init__.py,sha256=75Bh7eGhaN4sOt3ZcRzHl7pXaheu1z27kmTHeaI05vo,114
@@ -33,14 +33,14 @@ cognite/neat/_data_model/exporters/_base.py,sha256=rG_qAU5i5Hh5hUMep2UmDFFZID4x3
33
33
  cognite/neat/_data_model/exporters/_table_exporter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
34
  cognite/neat/_data_model/exporters/_table_exporter/exporter.py,sha256=4BPu_Chtjh1EyOaKbThXYohsqllVOkCbSoNekNZuBXc,5159
35
35
  cognite/neat/_data_model/exporters/_table_exporter/workbook.py,sha256=1Afk1WqeNe9tiNeSAm0HrF8jTQ1kTbIv1D9hMztKwO8,18482
36
- cognite/neat/_data_model/exporters/_table_exporter/writer.py,sha256=X5q0kGQA4LuIbT0DLpPbKBjut1x9JELBnc9PTFP8O4o,18163
36
+ cognite/neat/_data_model/exporters/_table_exporter/writer.py,sha256=QsO2BWB-_Jw_hpawHtG26NOnLu6wwtDosT-c1acNLPw,19270
37
37
  cognite/neat/_data_model/importers/__init__.py,sha256=dHnKnC_AXk42z6wzEHK15dxIOh8xSEkuUf_AFRZls0E,193
38
38
  cognite/neat/_data_model/importers/_api_importer.py,sha256=H8Ow3Tt7utuAuBhC6s7yWvhGqunHAtE0r0XRsVAr6IE,7280
39
39
  cognite/neat/_data_model/importers/_base.py,sha256=NRB0FcEBj4GaethU68nRffBfTedBBA866A3zfJNfmiQ,433
40
40
  cognite/neat/_data_model/importers/_table_importer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
41
- cognite/neat/_data_model/importers/_table_importer/data_classes.py,sha256=5gFeAjAYFlzOBb9cfKZWeih5psKynpJwPMNel7h6cO8,9496
41
+ cognite/neat/_data_model/importers/_table_importer/data_classes.py,sha256=7oy0dYXj2lW2F-9jzrosSAIlDBzcAewMu8e5sU5lHPw,9618
42
42
  cognite/neat/_data_model/importers/_table_importer/importer.py,sha256=lQ4_Gpv0haEwQEDYZJaxtR9dL6Y0ys9jbjFfWxH6s2o,8870
43
- cognite/neat/_data_model/importers/_table_importer/reader.py,sha256=v7_PWuOtUQwRTTkZRw4z37G2IF9BtrMozmnnaM9avKE,40759
43
+ cognite/neat/_data_model/importers/_table_importer/reader.py,sha256=I9-zHCpJLo7bj4BabAzSgNBDVUAocdhlvBfy95JkWRw,49451
44
44
  cognite/neat/_data_model/importers/_table_importer/source.py,sha256=h7u5ur5oetmvBs3wgj7Ody5uPF21QwxeAceoIhJ5qzo,3300
45
45
  cognite/neat/_data_model/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
46
  cognite/neat/_data_model/models/conceptual/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -49,12 +49,12 @@ cognite/neat/_data_model/models/conceptual/_concept.py,sha256=0Pk4W2TJ_Y0Z7oPHpz
49
49
  cognite/neat/_data_model/models/conceptual/_data_model.py,sha256=mSX0z8i29ufcRUhvC_NPeo2xGidlK3B1n89kngY_SqQ,1695
50
50
  cognite/neat/_data_model/models/conceptual/_properties.py,sha256=CpF37vJYBTLT4DH4ZOu2U-JyWtkb_27V8fw52qiaE_k,4007
51
51
  cognite/neat/_data_model/models/conceptual/_property.py,sha256=blSZQxX52zaILAtjUkldPzPeysz7wnG-UGSNU5tacI8,4138
52
- cognite/neat/_data_model/models/dms/__init__.py,sha256=q268ScyyIEv2KbcRtnC86ubk8KFie65mVlQbXG-Au0g,4446
52
+ cognite/neat/_data_model/models/dms/__init__.py,sha256=CW5NPMRrMyY4iyZgqYb8eZkRuwbbXUDSVNMWep3zEPI,5326
53
53
  cognite/neat/_data_model/models/dms/_base.py,sha256=931ODXnhrBrzf6vkjqu2IaFz8r2gGxuapn85yN_jkgg,889
54
- cognite/neat/_data_model/models/dms/_constants.py,sha256=_S-62UpDnBteLN2hLNCXBnHuMYSwK-cEBh3JKslv5GA,1343
54
+ cognite/neat/_data_model/models/dms/_constants.py,sha256=TaoE9kmNVEaTl_dDrZQL7YzgP4K13ff0Rc7nr4zbIgg,1384
55
55
  cognite/neat/_data_model/models/dms/_constraints.py,sha256=cyGgDlByXAuSMWJg7Oc25fkp33LsA61M927bCzTWlbo,1458
56
56
  cognite/neat/_data_model/models/dms/_container.py,sha256=wtQbNUwtpymltT1jav8wD4kIfjaIYnvhhz1KS0ffAbo,6044
57
- cognite/neat/_data_model/models/dms/_data_model.py,sha256=FyC5rJUGQmF32qBwVL2lEGf_Gw1Z438FfRr1dSNe9-Y,3151
57
+ cognite/neat/_data_model/models/dms/_data_model.py,sha256=tq_JGNN-1JxG46bhBhunZiLedklYbDXFEfINB0x3a3Q,3219
58
58
  cognite/neat/_data_model/models/dms/_data_types.py,sha256=FMt_d5aJD-o3s9VQWyyCVlHk7D_p3RlSNXBP1OACPs4,6424
59
59
  cognite/neat/_data_model/models/dms/_http.py,sha256=YIRRowqkphFAYkx3foTeLyPMe9fNnmzhUCBDXe0u9Kk,926
60
60
  cognite/neat/_data_model/models/dms/_indexes.py,sha256=ZtXe8ABuRcsAwRIZ9FCanS3uwZHpkOAhvDvjSXtx_Fs,900
@@ -63,8 +63,9 @@ cognite/neat/_data_model/models/dms/_references.py,sha256=x2sK_YnEpWtLED4j8dqrqV
63
63
  cognite/neat/_data_model/models/dms/_schema.py,sha256=2JFLcm52smzPdtZ69Lf02UbYAD8I_hpRbI7ZAzdxJJs,641
64
64
  cognite/neat/_data_model/models/dms/_space.py,sha256=jEOK_WTATaFDIfuUd6dCaDkW2ysKKQEkSUZf8dMyYWM,1903
65
65
  cognite/neat/_data_model/models/dms/_types.py,sha256=5-cgC53AG186OZUqkltv7pMjcGNLuH7Etbn8IUcgk1c,447
66
+ cognite/neat/_data_model/models/dms/_view_filter.py,sha256=cfeEOtRz5SGFI0rmPD3jNl-V6_zJxyxgxYjG3Oz8OEU,10301
66
67
  cognite/neat/_data_model/models/dms/_view_property.py,sha256=nJBPmw4KzJOdaQmvRfCE3A4FL-E13OsNUEufI64vLKo,9271
67
- cognite/neat/_data_model/models/dms/_views.py,sha256=ffNQx6atn7P-dhNBYmrfFAFALitFydGWL7Ome1_h5lY,8633
68
+ cognite/neat/_data_model/models/dms/_views.py,sha256=1yxuwnsUM4WKItEY1hmJbMQKW0q3Dn321NmLmKLJeCM,8657
68
69
  cognite/neat/_data_model/models/entities/__init__.py,sha256=7dDyES7fYl9LEREal59F038RdEvfGRpUOc6n_MtSgjU,836
69
70
  cognite/neat/_data_model/models/entities/_base.py,sha256=PaNrD29iwxuqTpRWbmESMTxRhhKXmRyDF_cLZEC69dg,3927
70
71
  cognite/neat/_data_model/models/entities/_constants.py,sha256=EK9Bus8UgFgxK5cVFMTAqWSl6aWkDe7d59hpUmlHlBs,517
@@ -311,7 +312,7 @@ cognite/neat/v0/session/engine/__init__.py,sha256=D3MxUorEs6-NtgoICqtZ8PISQrjrr4
311
312
  cognite/neat/v0/session/engine/_import.py,sha256=1QxA2_EK613lXYAHKQbZyw2yjo5P9XuiX4Z6_6-WMNQ,169
312
313
  cognite/neat/v0/session/engine/_interface.py,sha256=3W-cYr493c_mW3P5O6MKN1xEQg3cA7NHR_ev3zdF9Vk,533
313
314
  cognite/neat/v0/session/engine/_load.py,sha256=u0x7vuQCRoNcPt25KJBJRn8sJabonYK4vtSZpiTdP4k,5201
314
- cognite_neat-0.127.18.dist-info/METADATA,sha256=nYE2C-FdpTvrb-C3-64xoiRrHUZbXqVlR9Q4Svw4naQ,9150
315
- cognite_neat-0.127.18.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
316
- cognite_neat-0.127.18.dist-info/licenses/LICENSE,sha256=W8VmvFia4WHa3Gqxq1Ygrq85McUNqIGDVgtdvzT-XqA,11351
317
- cognite_neat-0.127.18.dist-info/RECORD,,
315
+ cognite_neat-0.127.20.dist-info/METADATA,sha256=947oG_DqzFdW87-nrRmFBMEpnHVuNcFj0VcHLYd9PAk,9150
316
+ cognite_neat-0.127.20.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
317
+ cognite_neat-0.127.20.dist-info/licenses/LICENSE,sha256=W8VmvFia4WHa3Gqxq1Ygrq85McUNqIGDVgtdvzT-XqA,11351
318
+ cognite_neat-0.127.20.dist-info/RECORD,,