cognite-neat 0.75.6__py3-none-any.whl → 0.75.8__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.

Potentially problematic release.


This version of cognite-neat might be problematic. Click here for more details.

Files changed (37) hide show
  1. cognite/neat/_version.py +1 -1
  2. cognite/neat/graph/extractors/_mock_graph_generator.py +6 -5
  3. cognite/neat/rules/analysis/_base.py +1 -1
  4. cognite/neat/rules/analysis/_information_rules.py +4 -4
  5. cognite/neat/rules/exporters/_models.py +6 -1
  6. cognite/neat/rules/exporters/_rules2dms.py +67 -14
  7. cognite/neat/rules/exporters/_rules2ontology.py +20 -17
  8. cognite/neat/rules/exporters/_validation.py +2 -2
  9. cognite/neat/rules/importers/_dms2rules.py +14 -15
  10. cognite/neat/rules/importers/_dtdl2rules/dtdl_converter.py +21 -19
  11. cognite/neat/rules/importers/_dtdl2rules/spec.py +1 -1
  12. cognite/neat/rules/importers/_owl2rules/_owl2rules.py +2 -2
  13. cognite/neat/rules/importers/_spreadsheet2rules.py +6 -1
  14. cognite/neat/rules/importers/_yaml2rules.py +8 -2
  15. cognite/neat/rules/issues/dms.py +1 -1
  16. cognite/neat/rules/models/data_types.py +282 -0
  17. cognite/neat/rules/models/entities.py +442 -0
  18. cognite/neat/rules/models/rdfpath.py +111 -11
  19. cognite/neat/rules/models/rules/_base.py +2 -2
  20. cognite/neat/rules/models/rules/_dms_architect_rules.py +117 -188
  21. cognite/neat/rules/models/rules/_dms_rules_write.py +355 -0
  22. cognite/neat/rules/models/rules/_dms_schema.py +3 -3
  23. cognite/neat/rules/models/rules/_domain_rules.py +6 -3
  24. cognite/neat/rules/models/rules/_information_rules.py +68 -61
  25. cognite/neat/rules/models/rules/_types/__init__.py +0 -47
  26. cognite/neat/rules/models/rules/_types/_base.py +1 -309
  27. cognite/neat/rules/models/rules/_types/_field.py +0 -225
  28. cognite/neat/utils/cdf_loaders/_data_modeling.py +4 -2
  29. cognite/neat/workflows/steps/lib/rules_exporter.py +97 -0
  30. cognite/neat/workflows/steps/lib/rules_importer.py +3 -3
  31. {cognite_neat-0.75.6.dist-info → cognite_neat-0.75.8.dist-info}/METADATA +1 -1
  32. {cognite_neat-0.75.6.dist-info → cognite_neat-0.75.8.dist-info}/RECORD +35 -34
  33. cognite/neat/rules/models/_entity.py +0 -142
  34. cognite/neat/rules/models/rules/_types/_value.py +0 -159
  35. {cognite_neat-0.75.6.dist-info → cognite_neat-0.75.8.dist-info}/LICENSE +0 -0
  36. {cognite_neat-0.75.6.dist-info → cognite_neat-0.75.8.dist-info}/WHEEL +0 -0
  37. {cognite_neat-0.75.6.dist-info → cognite_neat-0.75.8.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,442 @@
1
+ import re
2
+ import sys
3
+ from abc import ABC, abstractmethod
4
+ from functools import total_ordering
5
+ from typing import Annotated, Any, ClassVar, Generic, TypeVar, cast
6
+
7
+ from cognite.client.data_classes.data_modeling.ids import ContainerId, DataModelId, PropertyId, ViewId
8
+ from pydantic import AnyHttpUrl, BaseModel, BeforeValidator, Field, PlainSerializer, model_serializer, model_validator
9
+
10
+ if sys.version_info >= (3, 11):
11
+ from enum import StrEnum
12
+ from typing import Self
13
+ else:
14
+ from backports.strenum import StrEnum
15
+ from typing_extensions import Self
16
+
17
+
18
+ class EntityTypes(StrEnum):
19
+ view_non_versioned = "view_non_versioned"
20
+ subject = "subject"
21
+ predicate = "predicate"
22
+ object = "object"
23
+ class_ = "class"
24
+ parent_class = "parent_class"
25
+ property_ = "property"
26
+ object_property = "ObjectProperty"
27
+ data_property = "DatatypeProperty"
28
+ annotation_property = "AnnotationProperty"
29
+ object_value_type = "object_value_type"
30
+ data_value_type = "data_value_type" # these are strings, floats, ...
31
+ xsd_value_type = "xsd_value_type"
32
+ dms_value_type = "dms_value_type"
33
+ view = "view"
34
+ reference_entity = "reference_entity"
35
+ container = "container"
36
+ datamodel = "datamodel"
37
+ undefined = "undefined"
38
+
39
+
40
+ # ALLOWED
41
+ _ALLOWED_PATTERN = r"[^a-zA-Z0-9-_.]"
42
+
43
+ # FOR PARSING STRINGS:
44
+ _PREFIX_REGEX = r"[a-zA-Z]+[a-zA-Z0-9-_.]*[a-zA-Z0-9]+"
45
+ _SUFFIX_REGEX = r"[a-zA-Z0-9-_.]+[a-zA-Z0-9]|[-_.]*[a-zA-Z0-9]+"
46
+ _VERSION_REGEX = r"[a-zA-Z0-9]([.a-zA-Z0-9_-]{0,41}[a-zA-Z0-9])?"
47
+ _PROPERTY_REGEX = r"[a-zA-Z0-9][a-zA-Z0-9_-]*[a-zA-Z0-9]?"
48
+ _ENTITY_ID_REGEX = rf"{_PREFIX_REGEX}:({_SUFFIX_REGEX})"
49
+ _ENTITY_ID_REGEX_COMPILED = re.compile(rf"^(?P<prefix>{_PREFIX_REGEX}):(?P<suffix>{_SUFFIX_REGEX})$")
50
+ _VERSIONED_ENTITY_REGEX_COMPILED = re.compile(
51
+ rf"^(?P<prefix>{_PREFIX_REGEX}):(?P<suffix>{_SUFFIX_REGEX})\(version=(?P<version>{_VERSION_REGEX})\)$"
52
+ )
53
+ _CLASS_ID_REGEX = rf"(?P<{EntityTypes.class_}>{_ENTITY_ID_REGEX})"
54
+ _CLASS_ID_REGEX_COMPILED = re.compile(rf"^{_CLASS_ID_REGEX}$")
55
+ _PROPERTY_ID_REGEX = rf"\((?P<{EntityTypes.property_}>{_ENTITY_ID_REGEX})\)"
56
+
57
+ _ENTITY_PATTERN = re.compile(r"^(?P<prefix>.*?):?(?P<suffix>[^(:]*)(\((?P<content>[^)]+)\))?$")
58
+
59
+
60
+ class _UndefinedType(BaseModel):
61
+ ...
62
+
63
+
64
+ class _UnknownType(BaseModel):
65
+ def __str__(self) -> str:
66
+ return "#N/A"
67
+
68
+
69
+ # This is a trick to make Undefined and Unknown singletons
70
+ Undefined = _UndefinedType()
71
+ Unknown = _UnknownType()
72
+ _PARSE = object()
73
+
74
+
75
+ @total_ordering
76
+ class Entity(BaseModel, extra="ignore"):
77
+ """Entity is a class or property in OWL/RDF sense."""
78
+
79
+ type_: ClassVar[EntityTypes] = EntityTypes.undefined
80
+ prefix: str | _UndefinedType = Undefined
81
+ suffix: str
82
+
83
+ @classmethod
84
+ def load(cls: "type[T_Entity]", data: Any, **defaults) -> "T_Entity | UnknownEntity":
85
+ if isinstance(data, cls):
86
+ return data
87
+ elif isinstance(data, str) and data == str(Unknown):
88
+ return UnknownEntity(prefix=Undefined, suffix=Unknown)
89
+ if defaults and isinstance(defaults, dict):
90
+ # This is trick to pass in default values
91
+ return cls.model_validate({_PARSE: data, "defaults": defaults})
92
+ else:
93
+ return cls.model_validate(data)
94
+
95
+ @model_validator(mode="before")
96
+ def _load(cls, data: Any) -> "dict | Entity":
97
+ defaults = {}
98
+ if isinstance(data, dict) and _PARSE in data:
99
+ defaults = data.get("defaults", {})
100
+ data = data[_PARSE]
101
+ if isinstance(data, dict):
102
+ data.update(defaults)
103
+ return data
104
+ elif hasattr(data, "versioned_id"):
105
+ # Todo: Remove. Is here for backwards compatibility
106
+ data = data.versioned_id
107
+ elif not isinstance(data, str):
108
+ raise ValueError(f"Cannot load {cls.__name__} from {data}")
109
+ elif data == str(Unknown) and cls.type_ == EntityTypes.undefined:
110
+ return dict(prefix=Undefined, suffix=Unknown) # type: ignore[arg-type]
111
+ elif data == str(Unknown):
112
+ raise ValueError(f"Unknown is not allowed for {cls.type_} entity")
113
+
114
+ result = cls._parse(data)
115
+ output = defaults.copy()
116
+ # Populate by alias
117
+ for field_name, field_ in cls.model_fields.items():
118
+ name = field_.alias or field_name
119
+ if (field_value := result.get(field_name)) and not (field_value in [Unknown, Undefined] and name in output):
120
+ output[name] = result.pop(field_name)
121
+ elif name not in output and name in result:
122
+ output[name] = result.pop(name)
123
+ return output
124
+
125
+ @model_serializer(when_used="unless-none", return_type=str)
126
+ def as_str(self) -> str:
127
+ return str(self)
128
+
129
+ @classmethod
130
+ def _parse(cls, raw: str) -> dict:
131
+ if not (result := _ENTITY_PATTERN.match(raw)):
132
+ return dict(prefix=Undefined, suffix=Unknown)
133
+ prefix = result.group("prefix") or Undefined
134
+ suffix = result.group("suffix")
135
+ content = result.group("content")
136
+ if content is None:
137
+ return dict(prefix=prefix, suffix=suffix)
138
+ extra_args = dict(pair.strip().split("=") for pair in content.split(","))
139
+ expected_args = {field_.alias or field_name for field_name, field_ in cls.model_fields.items()}
140
+ for key in list(extra_args):
141
+ if key not in expected_args:
142
+ # Todo Warning about unknown key
143
+ del extra_args[key]
144
+ return dict(prefix=prefix, suffix=suffix, **extra_args)
145
+
146
+ def dump(self) -> str:
147
+ return str(self)
148
+
149
+ def as_tuple(self) -> tuple[str, ...]:
150
+ # We haver overwritten the serialization to str, so we need to do it manually
151
+ extra: tuple[str, ...] = tuple(
152
+ [
153
+ str(v or "")
154
+ for field_name in self.model_fields
155
+ if isinstance(v := getattr(self, field_name), str | None) and field_name not in {"prefix", "suffix"}
156
+ ]
157
+ )
158
+ if isinstance(self.prefix, _UndefinedType):
159
+ return str(self.suffix), *extra
160
+ else:
161
+ return self.prefix, str(self.suffix), *extra
162
+
163
+ def __lt__(self, other: object) -> bool:
164
+ if not isinstance(other, Entity):
165
+ return NotImplemented
166
+ return self.as_tuple() < other.as_tuple()
167
+
168
+ def __eq__(self, other: object) -> bool:
169
+ if not isinstance(other, Entity):
170
+ return NotImplemented
171
+ return self.as_tuple() == other.as_tuple()
172
+
173
+ def __hash__(self) -> int:
174
+ return hash(str(self))
175
+
176
+ def __str__(self) -> str:
177
+ return self.id
178
+
179
+ def __repr__(self) -> str:
180
+ # We have overwritten the serialization to str, so we need to do it manually
181
+ model_dump = ((k, v) for k in self.model_fields if (v := getattr(self, k)) is not None)
182
+ args = ",".join([f"{k}={v}" for k, v in model_dump])
183
+ return f"{self.type_.value}({args})"
184
+
185
+ @property
186
+ def id(self) -> str:
187
+ # We have overwritten the serialization to str, so we need to do it manually
188
+ model_dump = (
189
+ (field.alias or field_name, v)
190
+ for field_name, field in self.model_fields.items()
191
+ if (v := getattr(self, field_name)) is not None and field_name not in {"prefix", "suffix"}
192
+ )
193
+ args = ",".join([f"{k}={v}" for k, v in model_dump])
194
+ if self.prefix is Undefined:
195
+ base_id = str(self.suffix)
196
+ else:
197
+ base_id = f"{self.prefix}:{self.suffix!s}"
198
+ if args:
199
+ return f"{base_id}({args})"
200
+ else:
201
+ return base_id
202
+
203
+ @property
204
+ def versioned_id(self) -> str:
205
+ # Todo: Remove. Is here for backwards compatibility
206
+ return self.id
207
+
208
+ def as_non_versioned_entity(self) -> str:
209
+ # Todo: Remove. Is here for backwards compatibility
210
+ if self.prefix is Undefined:
211
+ return f"{self.suffix!s}"
212
+ return f"{self.prefix}:{self.suffix!s}"
213
+
214
+
215
+ T_Entity = TypeVar("T_Entity", bound=Entity)
216
+
217
+
218
+ class ClassEntity(Entity):
219
+ type_: ClassVar[EntityTypes] = EntityTypes.class_
220
+ version: str | None = None
221
+
222
+ def as_view_entity(self, default_space: str, default_version) -> "ViewEntity":
223
+ if self.version is None:
224
+ version = default_version
225
+ else:
226
+ version = self.version
227
+ space = default_space if isinstance(self.prefix, _UndefinedType) else self.prefix
228
+ return ViewEntity(space=space, externalId=str(self.suffix), version=version)
229
+
230
+ def as_container_entity(self, default_space: str) -> "ContainerEntity":
231
+ space = default_space if isinstance(self.prefix, _UndefinedType) else self.prefix
232
+ return ContainerEntity(space=space, externalId=str(self.suffix))
233
+
234
+
235
+ class ParentClassEntity(ClassEntity):
236
+ type_: ClassVar[EntityTypes] = EntityTypes.parent_class
237
+
238
+ def as_class_entity(self) -> ClassEntity:
239
+ return ClassEntity(prefix=self.prefix, suffix=self.suffix, version=self.version)
240
+
241
+
242
+ class UnknownEntity(ClassEntity):
243
+ type_: ClassVar[EntityTypes] = EntityTypes.undefined
244
+ prefix: _UndefinedType = Undefined
245
+ suffix: _UnknownType = Unknown # type: ignore[assignment]
246
+
247
+ @property
248
+ def id(self) -> str:
249
+ return str(Unknown)
250
+
251
+
252
+ T_ID = TypeVar("T_ID", bound=ContainerId | ViewId | DataModelId | PropertyId | None)
253
+
254
+
255
+ class DMSEntity(Entity, Generic[T_ID], ABC):
256
+ type_: ClassVar[EntityTypes] = EntityTypes.undefined
257
+ prefix: str = Field(alias="space")
258
+ suffix: str = Field(alias="externalId")
259
+
260
+ @classmethod
261
+ def load(cls: "type[T_DMSEntity]", data: Any, **defaults) -> "T_DMSEntity | DMSUnknownEntity": # type: ignore[override]
262
+ if isinstance(data, str) and data == str(Unknown):
263
+ return DMSUnknownEntity.from_id(None)
264
+ return cast(T_DMSEntity, super().load(data, **defaults))
265
+
266
+ @property
267
+ def space(self) -> str:
268
+ """Returns entity space in CDF."""
269
+ return self.prefix
270
+
271
+ @property
272
+ def external_id(self) -> str:
273
+ """Returns entity external id in CDF."""
274
+ return self.suffix
275
+
276
+ @abstractmethod
277
+ def as_id(self) -> T_ID:
278
+ raise NotImplementedError("Method as_id must be implemented in subclasses")
279
+
280
+ @classmethod
281
+ @abstractmethod
282
+ def from_id(cls, id: T_ID) -> Self:
283
+ raise NotImplementedError("Method from_id must be implemented in subclasses")
284
+
285
+ def as_class(self) -> ClassEntity:
286
+ return ClassEntity(prefix=self.space, suffix=self.external_id)
287
+
288
+
289
+ T_DMSEntity = TypeVar("T_DMSEntity", bound=DMSEntity)
290
+
291
+
292
+ class ContainerEntity(DMSEntity[ContainerId]):
293
+ type_: ClassVar[EntityTypes] = EntityTypes.container
294
+
295
+ def as_id(self) -> ContainerId:
296
+ return ContainerId(space=self.space, external_id=self.external_id)
297
+
298
+ @classmethod
299
+ def from_id(cls, id: ContainerId) -> "ContainerEntity":
300
+ return cls(space=id.space, externalId=id.external_id)
301
+
302
+
303
+ class DMSVersionedEntity(DMSEntity[T_ID], ABC):
304
+ version: str
305
+
306
+ def as_class(self) -> ClassEntity:
307
+ return ClassEntity(prefix=self.space, suffix=self.external_id, version=self.version)
308
+
309
+
310
+ class ViewEntity(DMSVersionedEntity[ViewId]):
311
+ type_: ClassVar[EntityTypes] = EntityTypes.view
312
+
313
+ def as_id(
314
+ self,
315
+ ) -> ViewId:
316
+ return ViewId(space=self.space, external_id=self.external_id, version=self.version)
317
+
318
+ @classmethod
319
+ def from_id(cls, id: ViewId) -> "ViewEntity":
320
+ if id.version is None:
321
+ raise ValueError("Version must be specified")
322
+ return cls(space=id.space, externalId=id.external_id, version=id.version)
323
+
324
+
325
+ class DMSUnknownEntity(DMSEntity[None]):
326
+ """This is a special entity that represents an unknown entity.
327
+
328
+ The use case is for direct relations where the source is not known."""
329
+
330
+ type_: ClassVar[EntityTypes] = EntityTypes.undefined
331
+ prefix: _UndefinedType = Field(Undefined, alias="space") # type: ignore[assignment]
332
+ suffix: _UnknownType = Field(Unknown, alias="externalId") # type: ignore[assignment]
333
+
334
+ def as_id(self) -> None:
335
+ return None
336
+
337
+ @classmethod
338
+ def from_id(cls, id: None) -> "DMSUnknownEntity":
339
+ return cls(space=Undefined, externalId=Unknown)
340
+
341
+ @property
342
+ def id(self) -> str:
343
+ return str(Unknown)
344
+
345
+
346
+ class ViewPropertyEntity(DMSVersionedEntity[PropertyId]):
347
+ type_: ClassVar[EntityTypes] = EntityTypes.property_
348
+ property_: str = Field(alias="property")
349
+
350
+ def as_id(self) -> PropertyId:
351
+ return PropertyId(source=ViewId(self.space, self.external_id, self.version), property=self.property_)
352
+
353
+ def as_view_id(self) -> ViewId:
354
+ return ViewId(space=self.space, external_id=self.external_id, version=self.version)
355
+
356
+ @classmethod
357
+ def from_id(cls, id: PropertyId) -> "ViewPropertyEntity":
358
+ if isinstance(id.source, ContainerId):
359
+ raise ValueError("Only view source are supported")
360
+ if id.source.version is None:
361
+ raise ValueError("Version must be specified")
362
+ return cls(
363
+ space=id.source.space, externalId=id.source.external_id, version=id.source.version, property=id.property
364
+ )
365
+
366
+
367
+ class DataModelEntity(DMSVersionedEntity[DataModelId]):
368
+ type_: ClassVar[EntityTypes] = EntityTypes.datamodel
369
+
370
+ def as_id(self) -> DataModelId:
371
+ return DataModelId(space=self.space, external_id=self.external_id, version=self.version)
372
+
373
+ @classmethod
374
+ def from_id(cls, id: DataModelId) -> "DataModelEntity":
375
+ if id.version is None:
376
+ raise ValueError("Version must be specified")
377
+ return cls(space=id.space, externalId=id.external_id, version=id.version)
378
+
379
+
380
+ class ReferenceEntity(ClassEntity):
381
+ type_: ClassVar[EntityTypes] = EntityTypes.reference_entity
382
+ prefix: str
383
+ property_: str | None = Field(None, alias="property")
384
+
385
+ def as_view_id(self) -> ViewId:
386
+ if isinstance(self.prefix, _UndefinedType) or isinstance(self.suffix, _UnknownType):
387
+ raise ValueError("Prefix is not defined or suffix is unknown")
388
+ return ViewId(space=self.prefix, external_id=self.suffix, version=self.version)
389
+
390
+ def as_view_property_id(self) -> PropertyId:
391
+ if self.property_ is None or self.prefix is Undefined or self.suffix is Unknown:
392
+ raise ValueError("Property is not defined or prefix is not defined or suffix is unknown")
393
+ return PropertyId(source=self.as_view_id(), property=self.property_)
394
+
395
+ def as_class_entity(self) -> ClassEntity:
396
+ return ClassEntity(prefix=self.prefix, suffix=self.suffix, version=self.version)
397
+
398
+
399
+ def _split_str(v: Any) -> list[str]:
400
+ if isinstance(v, str):
401
+ return v.replace(", ", ",").split(",")
402
+ return v
403
+
404
+
405
+ def _join_str(v: list[ClassEntity]) -> str | None:
406
+ return ",".join([entry.id for entry in v]) if v else None
407
+
408
+
409
+ ParentEntityList = Annotated[
410
+ list[ParentClassEntity],
411
+ BeforeValidator(_split_str),
412
+ PlainSerializer(
413
+ _join_str,
414
+ return_type=str,
415
+ when_used="unless-none",
416
+ ),
417
+ ]
418
+
419
+ ContainerEntityList = Annotated[
420
+ list[ContainerEntity],
421
+ BeforeValidator(_split_str),
422
+ PlainSerializer(
423
+ _join_str,
424
+ return_type=str,
425
+ when_used="unless-none",
426
+ ),
427
+ ]
428
+
429
+ ViewEntityList = Annotated[
430
+ list[ViewEntity],
431
+ BeforeValidator(_split_str),
432
+ PlainSerializer(
433
+ _join_str,
434
+ return_type=str,
435
+ when_used="unless-none",
436
+ ),
437
+ ]
438
+
439
+ URLEntity = Annotated[
440
+ AnyHttpUrl,
441
+ PlainSerializer(lambda v: str(v), return_type=str, when_used="unless-none"),
442
+ ]
@@ -4,22 +4,13 @@
4
4
  import re
5
5
  import sys
6
6
  from collections import Counter
7
- from typing import Literal
7
+ from functools import total_ordering
8
+ from typing import ClassVar, Literal
8
9
 
9
10
  from pydantic import BaseModel, field_validator
10
11
 
11
12
  from cognite.neat.rules import exceptions
12
13
 
13
- from ._entity import (
14
- CLASS_ID_REGEX,
15
- CLASS_ID_REGEX_COMPILED,
16
- ENTITY_ID_REGEX,
17
- PROPERTY_ID_REGEX,
18
- SUFFIX_REGEX,
19
- Entity,
20
- EntityTypes,
21
- )
22
-
23
14
  if sys.version_info >= (3, 11):
24
15
  from enum import StrEnum
25
16
  from typing import Self
@@ -40,6 +31,26 @@ class Lookup(StrEnum):
40
31
  value = "value" # type: ignore
41
32
 
42
33
 
34
+ class EntityTypes(StrEnum):
35
+ class_ = "class"
36
+ property_ = "property"
37
+ undefined = "undefined"
38
+
39
+
40
+ # FOR PARSING STRINGS:
41
+ PREFIX_REGEX = r"[a-zA-Z]+[a-zA-Z0-9-_.]*[a-zA-Z0-9]+"
42
+ SUFFIX_REGEX = r"[a-zA-Z0-9-_.]+[a-zA-Z0-9]|[-_.]*[a-zA-Z0-9]+"
43
+ VERSION_REGEX = r"[a-zA-Z0-9]([.a-zA-Z0-9_-]{0,41}[a-zA-Z0-9])?"
44
+
45
+ ENTITY_ID_REGEX = rf"{PREFIX_REGEX}:({SUFFIX_REGEX})"
46
+ ENTITY_ID_REGEX_COMPILED = re.compile(rf"^(?P<prefix>{PREFIX_REGEX}):(?P<suffix>{SUFFIX_REGEX})$")
47
+ VERSIONED_ENTITY_REGEX_COMPILED = re.compile(
48
+ rf"^(?P<prefix>{PREFIX_REGEX}):(?P<suffix>{SUFFIX_REGEX})\(version=(?P<version>{VERSION_REGEX})\)$"
49
+ )
50
+ CLASS_ID_REGEX = rf"(?P<{EntityTypes.class_}>{ENTITY_ID_REGEX})"
51
+ CLASS_ID_REGEX_COMPILED = re.compile(rf"^{CLASS_ID_REGEX}$")
52
+ PROPERTY_ID_REGEX = rf"\((?P<{EntityTypes.property_}>{ENTITY_ID_REGEX})\)"
53
+
43
54
  # traversal direction
44
55
  DIRECTION_REGEX = r"(?P<direction>(->|<-))"
45
56
 
@@ -73,6 +84,95 @@ TABLE_REGEX_COMPILED = re.compile(
73
84
  StepDirection = Literal["source", "target", "origin"]
74
85
  _direction_by_symbol: dict[str, StepDirection] = {"->": "target", "<-": "source"}
75
86
 
87
+ Undefined = type(object())
88
+ Unknown = type(object())
89
+
90
+
91
+ # mypy does not like the sentinel value, and it is not possible to ignore only the line with it below.
92
+ # so we ignore all errors beyond this point.
93
+ # mypy: ignore-errors
94
+ @total_ordering
95
+ class Entity(BaseModel, arbitrary_types_allowed=True):
96
+ """Entity is a class or property in OWL/RDF sense."""
97
+
98
+ type_: ClassVar[EntityTypes] = EntityTypes.undefined
99
+ prefix: str | Undefined = Undefined
100
+ suffix: str | Unknown
101
+ version: str | None = None
102
+ name: str | None = None
103
+ description: str | None = None
104
+
105
+ def __lt__(self, other: object) -> bool:
106
+ if type(self) is not type(other) or not isinstance(other, Entity):
107
+ return NotImplemented
108
+ return self.versioned_id < other.versioned_id
109
+
110
+ def __eq__(self, other: object) -> bool:
111
+ if type(self) is not type(other) or not isinstance(other, Entity):
112
+ return NotImplemented
113
+ return self.versioned_id == other.versioned_id
114
+
115
+ def __hash__(self) -> int:
116
+ return hash(self.versioned_id)
117
+
118
+ def as_non_versioned_entity(self) -> Self:
119
+ return self.from_string(f"{self.prefix}:{self.suffix}")
120
+
121
+ @property
122
+ def id(self) -> str:
123
+ if self.suffix is Unknown:
124
+ return "#N/A"
125
+ elif self.prefix is Undefined:
126
+ return self.suffix
127
+ else:
128
+ return f"{self.prefix}:{self.suffix}"
129
+
130
+ @property
131
+ def versioned_id(self) -> str:
132
+ if self.version is None:
133
+ return self.id
134
+ else:
135
+ return f"{self.id}(version={self.version})"
136
+
137
+ @property
138
+ def space(self) -> str:
139
+ """Returns entity space in CDF."""
140
+ return self.prefix
141
+
142
+ @property
143
+ def external_id(self) -> str:
144
+ """Returns entity external id in CDF."""
145
+ return self.suffix
146
+
147
+ def __repr__(self):
148
+ return self.versioned_id
149
+
150
+ def __str__(self):
151
+ return self.versioned_id
152
+
153
+ @classmethod
154
+ def from_string(cls, entity_string: str, base_prefix: str | None = None) -> Self:
155
+ if entity_string == "#N/A":
156
+ return cls(prefix=Undefined, suffix=Unknown)
157
+ elif result := VERSIONED_ENTITY_REGEX_COMPILED.match(entity_string):
158
+ return cls(
159
+ prefix=result.group("prefix"),
160
+ suffix=result.group("suffix"),
161
+ version=result.group("version"),
162
+ )
163
+ elif result := ENTITY_ID_REGEX_COMPILED.match(entity_string):
164
+ return cls(prefix=result.group("prefix"), suffix=result.group("suffix"))
165
+ elif base_prefix and re.match(SUFFIX_REGEX, entity_string) and re.match(PREFIX_REGEX, base_prefix):
166
+ return cls(prefix=base_prefix, suffix=entity_string)
167
+ else:
168
+ raise ValueError(f"{cls.__name__} is expected to be prefix:suffix, got {entity_string}")
169
+
170
+ @classmethod
171
+ def from_list(cls, entity_strings: list[str], base_prefix: str | None = None) -> list[Self]:
172
+ return [
173
+ cls.from_string(entity_string=entity_string, base_prefix=base_prefix) for entity_string in entity_strings
174
+ ]
175
+
76
176
 
77
177
  class Step(BaseModel):
78
178
  class_: Entity
@@ -27,7 +27,7 @@ from pydantic import (
27
27
  )
28
28
  from pydantic.fields import FieldInfo
29
29
 
30
- from cognite.neat.rules.models.rules._types import ClassType
30
+ from cognite.neat.rules.models.entities import ClassEntity
31
31
 
32
32
  if sys.version_info >= (3, 11):
33
33
  from enum import StrEnum
@@ -274,7 +274,7 @@ class BaseRules(RuleModel):
274
274
 
275
275
  # An sheet entity is either a class or a property.
276
276
  class SheetEntity(RuleModel):
277
- class_: ClassType = Field(alias="Class")
277
+ class_: ClassEntity = Field(alias="Class")
278
278
  name: str | None = Field(alias="Name", default=None)
279
279
  description: str | None = Field(alias="Description", default=None)
280
280