cognite-neat 1.0.10__py3-none-any.whl → 1.0.11__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.
- cognite/neat/__init__.py +2 -2
- cognite/neat/_config.py +14 -2
- cognite/neat/_data_model/_analysis.py +299 -150
- cognite/neat/_data_model/_snapshot.py +133 -0
- cognite/neat/_data_model/deployer/data_classes.py +1 -15
- cognite/neat/_data_model/deployer/deployer.py +2 -27
- cognite/neat/_data_model/models/dms/_references.py +6 -0
- cognite/neat/_data_model/models/dms/_views.py +6 -0
- cognite/neat/_data_model/validation/dms/_ai_readiness.py +44 -36
- cognite/neat/_data_model/validation/dms/_base.py +4 -329
- cognite/neat/_data_model/validation/dms/_connections.py +125 -91
- cognite/neat/_data_model/validation/dms/_consistency.py +12 -11
- cognite/neat/_data_model/validation/dms/_containers.py +57 -38
- cognite/neat/_data_model/validation/dms/_limits.py +43 -118
- cognite/neat/_data_model/validation/dms/_orchestrator.py +30 -186
- cognite/neat/_data_model/validation/dms/_views.py +29 -4
- cognite/neat/_session/_physical.py +42 -18
- cognite/neat/_session/_session.py +1 -1
- cognite/neat/_store/_store.py +21 -1
- cognite/neat/_version.py +1 -1
- {cognite_neat-1.0.10.dist-info → cognite_neat-1.0.11.dist-info}/METADATA +1 -1
- {cognite_neat-1.0.10.dist-info → cognite_neat-1.0.11.dist-info}/RECORD +23 -22
- {cognite_neat-1.0.10.dist-info → cognite_neat-1.0.11.dist-info}/WHEEL +0 -0
cognite/neat/__init__.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from cognite.neat._v0.core._utils.auth import get_cognite_client
|
|
2
2
|
|
|
3
|
-
from ._config import NeatConfig
|
|
3
|
+
from ._config import NeatConfig, get_neat_config_from_file
|
|
4
4
|
from ._session import NeatSession
|
|
5
5
|
from ._version import __version__
|
|
6
6
|
|
|
7
|
-
__all__ = ["NeatConfig", "NeatSession", "__version__", "get_cognite_client"]
|
|
7
|
+
__all__ = ["NeatConfig", "NeatSession", "__version__", "get_cognite_client", "get_neat_config_from_file"]
|
cognite/neat/_config.py
CHANGED
|
@@ -121,7 +121,7 @@ class AlphaFlagConfig(ConfigModel):
|
|
|
121
121
|
|
|
122
122
|
|
|
123
123
|
class NeatConfig(ConfigModel):
|
|
124
|
-
"""
|
|
124
|
+
"""NeatSession configuration."""
|
|
125
125
|
|
|
126
126
|
profile: str
|
|
127
127
|
validation: ValidationConfig
|
|
@@ -139,7 +139,19 @@ class NeatConfig(ConfigModel):
|
|
|
139
139
|
|
|
140
140
|
@classmethod
|
|
141
141
|
def create_predefined(cls, profile: PredefinedProfile = "legacy-additive") -> "NeatConfig":
|
|
142
|
-
"""Create NeatConfig from internal profiles.
|
|
142
|
+
"""Create NeatConfig from internal profiles.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
profile: Profile name to use. Defaults to "legacy-additive".
|
|
146
|
+
|
|
147
|
+
!!! note "Predefined Profiles"
|
|
148
|
+
The following predefined profiles are available:
|
|
149
|
+
|
|
150
|
+
- `legacy-additive`: Additive modeling with legacy validation rules.
|
|
151
|
+
- `legacy-rebuild`: Rebuild modeling with legacy validation rules.
|
|
152
|
+
- `deep-additive`: Additive modeling with deep validation rules.
|
|
153
|
+
- `deep-rebuild`: Rebuild modeling with deep validation rules.
|
|
154
|
+
"""
|
|
143
155
|
available_profiles = internal_profiles()
|
|
144
156
|
if profile not in available_profiles:
|
|
145
157
|
raise UserInputError(
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
from
|
|
1
|
+
from itertools import chain
|
|
2
|
+
from typing import Literal, TypeAlias
|
|
2
3
|
|
|
3
|
-
from
|
|
4
|
-
|
|
4
|
+
from pyparsing import cached_property
|
|
5
|
+
|
|
6
|
+
from cognite.neat._data_model._snapshot import SchemaSnapshot
|
|
7
|
+
from cognite.neat._data_model.models.dms._container import ContainerRequest
|
|
5
8
|
from cognite.neat._data_model.models.dms._data_types import DirectNodeRelation
|
|
9
|
+
from cognite.neat._data_model.models.dms._limits import SchemaLimits
|
|
6
10
|
from cognite.neat._data_model.models.dms._references import (
|
|
7
11
|
ContainerDirectReference,
|
|
8
12
|
ContainerReference,
|
|
9
13
|
ViewDirectReference,
|
|
10
14
|
ViewReference,
|
|
11
15
|
)
|
|
12
|
-
from cognite.neat._data_model.models.dms._schema import RequestSchema
|
|
13
16
|
from cognite.neat._data_model.models.dms._view_property import (
|
|
14
17
|
EdgeProperty,
|
|
15
18
|
ReverseDirectRelationProperty,
|
|
@@ -17,180 +20,326 @@ from cognite.neat._data_model.models.dms._view_property import (
|
|
|
17
20
|
ViewRequestProperty,
|
|
18
21
|
)
|
|
19
22
|
from cognite.neat._data_model.models.dms._views import ViewRequest
|
|
23
|
+
from cognite.neat._utils.useful_types import ModusOperandi
|
|
20
24
|
|
|
25
|
+
# Type aliases for better readability
|
|
26
|
+
ViewsByReference: TypeAlias = dict[ViewReference, ViewRequest]
|
|
27
|
+
ContainersByReference: TypeAlias = dict[ContainerReference, ContainerRequest]
|
|
28
|
+
AncestorsByReference: TypeAlias = dict[ViewReference, set[ViewReference]]
|
|
29
|
+
ReverseToDirectMapping: TypeAlias = dict[
|
|
30
|
+
tuple[ViewReference, str], tuple[ViewReference, ContainerDirectReference | ViewDirectReference]
|
|
31
|
+
]
|
|
32
|
+
ConnectionEndNodeTypes: TypeAlias = dict[tuple[ViewReference, str], ViewReference | None]
|
|
21
33
|
|
|
22
|
-
class DataModelAnalysis:
|
|
23
|
-
def __init__(
|
|
24
|
-
self,
|
|
25
|
-
physical: RequestSchema | None = None,
|
|
26
|
-
) -> None:
|
|
27
|
-
self._physical = physical
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
def physical(self) -> RequestSchema:
|
|
31
|
-
if self._physical is None:
|
|
32
|
-
raise ValueError("Physical Data Model is required for this analysis")
|
|
33
|
-
return self._physical
|
|
35
|
+
ResourceSource = Literal["auto", "merged", "cdf", "both"]
|
|
34
36
|
|
|
35
|
-
def referenced_views(self, include_connection_end_node_types: bool = False) -> set[ViewReference]:
|
|
36
|
-
"""Get all referenced views in the physical data model."""
|
|
37
|
-
referenced_views = set()
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
class ValidationResources:
|
|
39
|
+
def __init__(
|
|
40
|
+
self, modus_operandi: ModusOperandi, local: SchemaSnapshot, cdf: SchemaSnapshot, limits: SchemaLimits
|
|
41
|
+
) -> None:
|
|
42
|
+
self._modus_operandi = modus_operandi
|
|
43
|
+
self.limits = limits
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
self.local = local
|
|
46
|
+
self.cdf = cdf
|
|
47
|
+
|
|
48
|
+
if self._modus_operandi == "additive":
|
|
49
|
+
self.merged = self.local.merge(self.cdf)
|
|
50
|
+
elif self._modus_operandi == "rebuild":
|
|
51
|
+
self.merged = local.model_copy(deep=True)
|
|
52
|
+
else:
|
|
53
|
+
raise RuntimeError(f"ValidationResources: Unknown modus_operandi: {self._modus_operandi}. This is a bug!")
|
|
54
|
+
|
|
55
|
+
# need this shortcut for easier access and also to avoid mypy to complains
|
|
56
|
+
self.merged_data_model = self.merged.data_model[next(iter(self.merged.data_model.keys()))]
|
|
57
|
+
|
|
58
|
+
def select_view(
|
|
59
|
+
self, view_ref: ViewReference, property_: str | None = None, source: ResourceSource = "auto"
|
|
60
|
+
) -> ViewRequest | None:
|
|
61
|
+
"""Select view definition based on source strategy and optionally filter by property.
|
|
62
|
+
|
|
63
|
+
Selection prioritize merged view over CDF if both are available, as merged view represents the effective
|
|
64
|
+
definition which will be a result when the local schema is deployed to CDF.
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
view_ref: The view to select
|
|
69
|
+
property_: Optional property name to filter views that contain this property
|
|
70
|
+
source: The source strategy to use, options are: "auto", "local", "cdf", "both", where "auto" means
|
|
71
|
+
that the selection is driven by the modus_operandi of the validation resources.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The selected ViewRequest or None if not found.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
check_merged, check_cdf = self._resolve_resource_sources(view_ref, source)
|
|
78
|
+
|
|
79
|
+
merged_view = self.merged.views.get(view_ref) if check_merged else None
|
|
80
|
+
cdf_view = self.cdf.views.get(view_ref) if check_cdf else None
|
|
81
|
+
|
|
82
|
+
if property_ is None:
|
|
83
|
+
return merged_view or cdf_view
|
|
84
|
+
|
|
85
|
+
# Filtering based on the property presence
|
|
86
|
+
# Try views with the property first, then any available view where merged view is prioritized
|
|
87
|
+
candidates = chain(
|
|
88
|
+
(v for v in (merged_view, cdf_view) if v and v.properties and property_ in v.properties),
|
|
89
|
+
(v for v in (merged_view, cdf_view) if v),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return next(candidates, None)
|
|
93
|
+
|
|
94
|
+
def select_container(
|
|
95
|
+
self, container_ref: ContainerReference, property_: str | None = None, source: ResourceSource = "auto"
|
|
96
|
+
) -> ContainerRequest | None:
|
|
97
|
+
"""Select container definition based on source strategy and optionally filter by property.
|
|
98
|
+
|
|
99
|
+
Selection prioritize merged container over CDF if both are available, as merged container represents
|
|
100
|
+
the effective definition which will be a result when the local schema is deployed to CDF.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
container_ref: The container to select
|
|
104
|
+
property_: Optional property name to filter containers that contain this property
|
|
105
|
+
source: The source strategy to use, options are: "auto", "local", "cdf", "both", where "auto" means
|
|
106
|
+
that the selection is driven by the modus_operandi of the validation resources.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The selected ContainerRequest or None if not found.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
check_merged, check_cdf = self._resolve_resource_sources(container_ref, source)
|
|
113
|
+
|
|
114
|
+
merged_container = self.merged.containers.get(container_ref) if check_merged else None
|
|
115
|
+
cdf_container = self.cdf.containers.get(container_ref) if check_cdf else None
|
|
116
|
+
|
|
117
|
+
if property_ is None:
|
|
118
|
+
return merged_container or cdf_container
|
|
119
|
+
|
|
120
|
+
# Try containers with the property first, then any available container
|
|
121
|
+
candidates = chain(
|
|
122
|
+
(c for c in (merged_container, cdf_container) if c and c.properties and property_ in c.properties),
|
|
123
|
+
(c for c in (merged_container, cdf_container) if c),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return next(candidates, None)
|
|
127
|
+
|
|
128
|
+
def _resolve_resource_sources(
|
|
129
|
+
self, resource_ref: ViewReference | ContainerReference, source: ResourceSource
|
|
130
|
+
) -> tuple[bool, bool]:
|
|
131
|
+
"""
|
|
132
|
+
Determine which resource sources (merged and/or CDF) to check based on the source parameter.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
resource_ref: The resource reference to check (ViewReference or ContainerReference)
|
|
136
|
+
source: The source strategy to use
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Tuple of (check_merged, check_cdf) booleans indicating which sources to check
|
|
140
|
+
"""
|
|
141
|
+
if source == "auto":
|
|
142
|
+
# Auto mode: driven by modus_operandi
|
|
143
|
+
# In "additive" mode or for resources outside local space, check both merged and CDF
|
|
144
|
+
# In "rebuild" mode for resources in local space, check only merged == local
|
|
145
|
+
check_merged = True
|
|
146
|
+
check_cdf = resource_ref.space != self.merged_data_model.space or self._modus_operandi == "additive"
|
|
147
|
+
elif source == "merged":
|
|
148
|
+
check_merged = True
|
|
149
|
+
check_cdf = False
|
|
150
|
+
elif source == "cdf":
|
|
151
|
+
check_merged = False
|
|
152
|
+
check_cdf = True
|
|
153
|
+
elif source == "both":
|
|
154
|
+
check_merged = True
|
|
155
|
+
check_cdf = True
|
|
156
|
+
else:
|
|
157
|
+
raise RuntimeError(f"_resolve_resource_sources: Unknown source: {source}. This is a bug!")
|
|
158
|
+
|
|
159
|
+
return check_merged, check_cdf
|
|
160
|
+
|
|
161
|
+
@cached_property
|
|
162
|
+
def ancestors_by_view(self) -> dict[ViewReference, list[ViewReference]]:
|
|
163
|
+
"""
|
|
164
|
+
Create a mapping of each view to its list of ancestors.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Dictionary mapping each ViewReference to its list of ancestor ViewReferences
|
|
168
|
+
"""
|
|
169
|
+
ancestors_mapping: dict[ViewReference, list[ViewReference]] = {}
|
|
170
|
+
|
|
171
|
+
if not self.merged_data_model.views:
|
|
172
|
+
return ancestors_mapping
|
|
173
|
+
|
|
174
|
+
for view in self.merged_data_model.views:
|
|
175
|
+
ancestors_mapping[view] = self.view_ancestors(view)
|
|
176
|
+
return ancestors_mapping
|
|
177
|
+
|
|
178
|
+
def view_ancestors(
|
|
179
|
+
self, offspring: ViewReference, ancestors: list[ViewReference] | None = None, source: ResourceSource = "auto"
|
|
180
|
+
) -> list[ViewReference]:
|
|
181
|
+
"""
|
|
182
|
+
Recursively find all ancestors of a given view by traversing the implements hierarchy.
|
|
183
|
+
Handles branching to explore all possible ancestor paths.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
offspring: The view to find ancestors for
|
|
187
|
+
ancestors: Accumulated list of ancestors (used internally for recursion)
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
List of all ancestor ViewReferences
|
|
191
|
+
"""
|
|
192
|
+
if ancestors is None:
|
|
193
|
+
ancestors = []
|
|
194
|
+
|
|
195
|
+
# Determine which view definition to use based on space and modus operandi
|
|
196
|
+
|
|
197
|
+
view_definition = self.select_view(view_ref=offspring, source=source)
|
|
198
|
+
|
|
199
|
+
# Base case: no view definition or no implements
|
|
200
|
+
if not view_definition or not view_definition.implements:
|
|
201
|
+
return ancestors
|
|
202
|
+
|
|
203
|
+
# Explore all parent branches
|
|
204
|
+
for parent in view_definition.implements:
|
|
205
|
+
if parent not in ancestors:
|
|
206
|
+
ancestors.append(parent)
|
|
207
|
+
# Recursively explore this branch
|
|
208
|
+
self.view_ancestors(parent, ancestors)
|
|
209
|
+
|
|
210
|
+
return ancestors
|
|
211
|
+
|
|
212
|
+
def is_ancestor(self, offspring: ViewReference, ancestor: ViewReference) -> bool:
|
|
213
|
+
return ancestor in self.ancestors_by_view.get(offspring, set())
|
|
214
|
+
|
|
215
|
+
@cached_property
|
|
216
|
+
def properties_by_view(self) -> dict[ViewReference, dict[str, ViewRequestProperty]]:
|
|
217
|
+
"""Get a mapping of view references to their corresponding properties, both directly defined and inherited
|
|
218
|
+
from ancestor views through implements."""
|
|
47
219
|
|
|
48
|
-
|
|
220
|
+
properties_mapping: dict[ViewReference, dict[str, ViewRequestProperty]] = {}
|
|
49
221
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
222
|
+
if self.merged_data_model.views:
|
|
223
|
+
for view_ref in self.merged_data_model.views:
|
|
224
|
+
view = self.select_view(view_ref)
|
|
225
|
+
# This should never happen, if it happens, it's a bug
|
|
226
|
+
if not view:
|
|
227
|
+
raise RuntimeError(f"properties_by_view: View {view_ref!s} not found. This is a bug!")
|
|
54
228
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
229
|
+
combined_properties: dict[str, ViewRequestProperty] = {}
|
|
230
|
+
ancestors = self.ancestors_by_view.get(view_ref, [])
|
|
231
|
+
# Start with properties from ancestor views
|
|
232
|
+
for ancestor_ref in reversed(ancestors):
|
|
233
|
+
ancestor_view = self.select_view(ancestor_ref)
|
|
234
|
+
if ancestor_view:
|
|
235
|
+
combined_properties.update(ancestor_view.properties)
|
|
59
236
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
for constraint in container.constraints.values():
|
|
64
|
-
if isinstance(constraint, RequiresConstraintDefinition):
|
|
65
|
-
referenced_containers.add(constraint.require)
|
|
237
|
+
# Finally, add properties from the current view, overriding any inherited ones
|
|
238
|
+
if view.properties:
|
|
239
|
+
combined_properties.update(view.properties)
|
|
66
240
|
|
|
67
|
-
|
|
241
|
+
properties_mapping[view_ref] = combined_properties
|
|
68
242
|
|
|
69
|
-
|
|
70
|
-
"""Get a mapping of view references to their corresponding ViewRequest objects."""
|
|
71
|
-
view_ancestors = self.ancestors_by_view(self.physical.views)
|
|
72
|
-
|
|
73
|
-
view_by_reference: dict[ViewReference, ViewRequest] = {
|
|
74
|
-
view.as_reference(): view.model_copy(deep=True) for view in self.physical.views
|
|
75
|
-
}
|
|
243
|
+
return properties_mapping
|
|
76
244
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if ancestor_view := view_by_reference.get(ancestor):
|
|
81
|
-
if ancestor_view.properties:
|
|
82
|
-
view.properties.update(ancestor_view.properties)
|
|
83
|
-
|
|
84
|
-
return view_by_reference
|
|
245
|
+
@cached_property
|
|
246
|
+
def referenced_containers(self) -> set[ContainerReference]:
|
|
247
|
+
"""Get a set of all container references used by the views in the local data model."""
|
|
85
248
|
|
|
86
|
-
|
|
87
|
-
def properties_by_view(self) -> dict[ViewReference, dict[str, ViewRequestProperty]]:
|
|
88
|
-
"""Get a mapping of view references to their corresponding properties."""
|
|
89
|
-
view_by_reference = self.view_by_reference(include_inherited_properties=False)
|
|
90
|
-
return {
|
|
91
|
-
view_ref: {prop_name: prop for prop_name, prop in view.properties.items()}
|
|
92
|
-
for view_ref, view in view_by_reference.items()
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
@staticmethod
|
|
96
|
-
def ancestors_by_view(views: list[ViewRequest]) -> dict[ViewReference, set[ViewReference]]:
|
|
97
|
-
"""Get a mapping of each view to its ancestors in the physical data model."""
|
|
98
|
-
implements_by_view = DataModelAnalysis.implements_by_view(views)
|
|
99
|
-
|
|
100
|
-
# Topological sort to ensure that concepts include all ancestors
|
|
101
|
-
for view in list(TopologicalSorter(implements_by_view).static_order()):
|
|
102
|
-
if view not in implements_by_view:
|
|
103
|
-
continue
|
|
104
|
-
implements_by_view[view] |= {
|
|
105
|
-
grand_parent
|
|
106
|
-
for parent in implements_by_view[view]
|
|
107
|
-
for grand_parent in implements_by_view.get(parent, set())
|
|
108
|
-
}
|
|
249
|
+
referenced_containers: set[ContainerReference] = set()
|
|
109
250
|
|
|
110
|
-
|
|
251
|
+
if not self.merged_data_model.views:
|
|
252
|
+
return referenced_containers
|
|
111
253
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
254
|
+
for view_ref in self.merged_data_model.views:
|
|
255
|
+
view = self.select_view(view_ref)
|
|
256
|
+
# This should never happen, if it happens, it's a bug
|
|
257
|
+
if not view:
|
|
258
|
+
raise RuntimeError(f"referenced_containers: View {view_ref!s} not found. This is a bug!")
|
|
116
259
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
for implement in view.implements:
|
|
123
|
-
implements_mapping[view_ref].add(implement)
|
|
260
|
+
if not view.properties:
|
|
261
|
+
continue
|
|
262
|
+
for property_ in view.properties.values():
|
|
263
|
+
if isinstance(property_, ViewCorePropertyRequest):
|
|
264
|
+
referenced_containers.add(property_.container)
|
|
124
265
|
|
|
125
|
-
return
|
|
266
|
+
return referenced_containers
|
|
126
267
|
|
|
127
|
-
@
|
|
128
|
-
def
|
|
129
|
-
|
|
130
|
-
|
|
268
|
+
@cached_property
|
|
269
|
+
def reverse_to_direct_mapping(
|
|
270
|
+
self,
|
|
271
|
+
) -> dict[tuple[ViewReference, str], tuple[ViewReference, ContainerDirectReference | ViewDirectReference]]:
|
|
272
|
+
"""Get a mapping of reverse direct relations to their corresponding source view and 'through' property."""
|
|
131
273
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
274
|
+
bidirectional_connections: dict[
|
|
275
|
+
tuple[ViewReference, str], tuple[ViewReference, ContainerDirectReference | ViewDirectReference]
|
|
276
|
+
] = {}
|
|
277
|
+
|
|
278
|
+
if self.merged_data_model.views:
|
|
279
|
+
for view_ref in self.merged_data_model.views:
|
|
280
|
+
view = self.select_view(view_ref)
|
|
281
|
+
|
|
282
|
+
# This should never happen, if it happens, it's a bug
|
|
283
|
+
if not view:
|
|
284
|
+
raise RuntimeError(f"reverse_to_direct_mapping: View {view_ref!s} not found. This is a bug!")
|
|
285
|
+
|
|
286
|
+
if not view.properties:
|
|
287
|
+
continue
|
|
288
|
+
for prop_ref, property_ in view.properties.items():
|
|
289
|
+
# reverse direct relation
|
|
290
|
+
if isinstance(property_, ReverseDirectRelationProperty):
|
|
291
|
+
bidirectional_connections[(view_ref, prop_ref)] = (
|
|
292
|
+
property_.source,
|
|
293
|
+
property_.through,
|
|
294
|
+
)
|
|
135
295
|
|
|
136
|
-
return
|
|
137
|
-
(container.as_reference(), prop_name): property_
|
|
138
|
-
for container in self.physical.containers
|
|
139
|
-
for prop_name, property_ in container.properties.items()
|
|
140
|
-
}
|
|
296
|
+
return bidirectional_connections
|
|
141
297
|
|
|
142
298
|
@property
|
|
143
299
|
def connection_end_node_types(self) -> dict[tuple[ViewReference, str], ViewReference | None]:
|
|
144
300
|
"""Get a mapping of view references to their corresponding ViewRequest objects."""
|
|
145
|
-
|
|
301
|
+
|
|
146
302
|
connection_end_node_types: dict[tuple[ViewReference, str], ViewReference | None] = {}
|
|
147
|
-
container_properties = self.container_properties
|
|
148
303
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
304
|
+
if self.merged_data_model.views:
|
|
305
|
+
for view_ref in self.merged_data_model.views:
|
|
306
|
+
view = self.select_view(view_ref)
|
|
307
|
+
if not view:
|
|
308
|
+
raise RuntimeError(f"View {view_ref!s} not found. This is a bug!")
|
|
309
|
+
|
|
310
|
+
if not view.properties:
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
for prop_ref, property_ in view.properties.items():
|
|
314
|
+
# direct relation
|
|
315
|
+
if isinstance(property_, ViewCorePropertyRequest):
|
|
316
|
+
# explicit set of end node type via 'source' which is View reference
|
|
317
|
+
if property_.source:
|
|
318
|
+
connection_end_node_types[(view_ref, prop_ref)] = property_.source
|
|
319
|
+
|
|
320
|
+
# implicit end node type via container property, without actual knowledge of end node type
|
|
321
|
+
elif (
|
|
322
|
+
(
|
|
323
|
+
container := self.select_container(
|
|
324
|
+
property_.container, property_.container_property_identifier
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
and (property_.container_property_identifier in container.properties)
|
|
328
|
+
and (
|
|
329
|
+
isinstance(
|
|
330
|
+
container.properties[property_.container_property_identifier].type,
|
|
331
|
+
DirectNodeRelation,
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
):
|
|
335
|
+
connection_end_node_types[(view_ref, prop_ref)] = None
|
|
336
|
+
|
|
337
|
+
# reverse direct relation
|
|
338
|
+
elif isinstance(property_, ReverseDirectRelationProperty) and property_.source:
|
|
157
339
|
connection_end_node_types[(view_ref, prop_ref)] = property_.source
|
|
158
340
|
|
|
159
|
-
#
|
|
160
|
-
elif (
|
|
161
|
-
|
|
162
|
-
(property_.container, property_.container_property_identifier)
|
|
163
|
-
)
|
|
164
|
-
) and isinstance(container_property.type, DirectNodeRelation):
|
|
165
|
-
connection_end_node_types[(view_ref, prop_ref)] = None
|
|
166
|
-
|
|
167
|
-
# reverse direct relation
|
|
168
|
-
if isinstance(property_, ReverseDirectRelationProperty) and property_.source:
|
|
169
|
-
connection_end_node_types[(view_ref, prop_ref)] = property_.source
|
|
170
|
-
|
|
171
|
-
# edge property
|
|
172
|
-
if isinstance(property_, EdgeProperty) and property_.source:
|
|
173
|
-
connection_end_node_types[(view_ref, prop_ref)] = property_.source
|
|
341
|
+
# edge property
|
|
342
|
+
elif isinstance(property_, EdgeProperty) and property_.source:
|
|
343
|
+
connection_end_node_types[(view_ref, prop_ref)] = property_.source
|
|
174
344
|
|
|
175
345
|
return connection_end_node_types
|
|
176
|
-
|
|
177
|
-
@property
|
|
178
|
-
def reverse_to_direct_mapping(
|
|
179
|
-
self,
|
|
180
|
-
) -> dict[tuple[ViewReference, str], tuple[ViewReference, ContainerDirectReference | ViewDirectReference]]:
|
|
181
|
-
"""Get a mapping of reverse direct relations to their corresponding source view and 'through' property."""
|
|
182
|
-
view_by_reference = self.view_by_reference(include_inherited_properties=False)
|
|
183
|
-
bidirectional_connections = {}
|
|
184
|
-
|
|
185
|
-
for view_ref, view in view_by_reference.items():
|
|
186
|
-
if not view.properties:
|
|
187
|
-
continue
|
|
188
|
-
for prop_ref, property_ in view.properties.items():
|
|
189
|
-
# reverse direct relation
|
|
190
|
-
if isinstance(property_, ReverseDirectRelationProperty):
|
|
191
|
-
bidirectional_connections[(view_ref, prop_ref)] = (
|
|
192
|
-
property_.source,
|
|
193
|
-
property_.through,
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
return bidirectional_connections
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, field_serializer
|
|
6
|
+
from pydantic_core.core_schema import FieldSerializationInfo
|
|
7
|
+
|
|
8
|
+
from cognite.neat._client import NeatClient
|
|
9
|
+
from cognite.neat._data_model.models.dms import (
|
|
10
|
+
ContainerReference,
|
|
11
|
+
ContainerRequest,
|
|
12
|
+
DataModelReference,
|
|
13
|
+
DataModelRequest,
|
|
14
|
+
NodeReference,
|
|
15
|
+
RequestSchema,
|
|
16
|
+
SpaceReference,
|
|
17
|
+
SpaceRequest,
|
|
18
|
+
ViewReference,
|
|
19
|
+
ViewRequest,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if sys.version_info >= (3, 11):
|
|
23
|
+
from typing import Self
|
|
24
|
+
else:
|
|
25
|
+
from typing_extensions import Self
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SchemaSnapshot(BaseModel, extra="ignore"):
|
|
29
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
30
|
+
data_model: dict[DataModelReference, DataModelRequest] = Field(default_factory=dict)
|
|
31
|
+
views: dict[ViewReference, ViewRequest] = Field(default_factory=dict)
|
|
32
|
+
containers: dict[ContainerReference, ContainerRequest] = Field(default_factory=dict)
|
|
33
|
+
spaces: dict[SpaceReference, SpaceRequest] = Field(default_factory=dict)
|
|
34
|
+
node_types: dict[NodeReference, NodeReference] = Field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
@field_serializer("data_model", "views", "containers", "spaces", "node_types", mode="plain")
|
|
37
|
+
@classmethod
|
|
38
|
+
def make_hashable_keys(cls, value: dict, info: FieldSerializationInfo) -> dict[str, Any]:
|
|
39
|
+
output: dict[str, Any] = {}
|
|
40
|
+
for key, val in value.items():
|
|
41
|
+
dumped_value = val.model_dump(**vars(info))
|
|
42
|
+
output[str(key)] = dumped_value
|
|
43
|
+
return output
|
|
44
|
+
|
|
45
|
+
def merge(self, cdf: Self) -> Self:
|
|
46
|
+
"""Merge another SchemaSnapshot into this one, prioritizing this snapshot's data."""
|
|
47
|
+
merged = self.model_copy(deep=True)
|
|
48
|
+
|
|
49
|
+
for model_ref, local_model in merged.data_model.items():
|
|
50
|
+
if model_ref not in cdf.data_model:
|
|
51
|
+
continue
|
|
52
|
+
cdf_model = cdf.data_model[model_ref]
|
|
53
|
+
if new_views := (set(cdf_model.views or []) - set(local_model.views or [])):
|
|
54
|
+
for view_ref in new_views:
|
|
55
|
+
if cdf_view := cdf.views.get(view_ref):
|
|
56
|
+
merged.views[view_ref] = cdf_view
|
|
57
|
+
# We append the local views at the end of the CDF views.
|
|
58
|
+
local_model.views = list(dict.fromkeys((cdf_model.views or []) + (local_model.views or [])).keys())
|
|
59
|
+
|
|
60
|
+
# Update local views with additional properties and implements from CDF views
|
|
61
|
+
for view_ref, view in merged.views.items():
|
|
62
|
+
if cdf_view := cdf.views.get(view_ref):
|
|
63
|
+
if cdf_only_containers := cdf_view.used_containers - set(view.used_containers):
|
|
64
|
+
for cdf_only_container_ref in cdf_only_containers:
|
|
65
|
+
if (
|
|
66
|
+
cdf_container := cdf.containers.get(cdf_only_container_ref)
|
|
67
|
+
) and cdf_only_container_ref not in merged.containers:
|
|
68
|
+
merged.containers[cdf_only_container_ref] = cdf_container
|
|
69
|
+
|
|
70
|
+
view.properties = {**cdf_view.properties, **view.properties}
|
|
71
|
+
|
|
72
|
+
# update implements
|
|
73
|
+
if cdf_view.implements:
|
|
74
|
+
view.implements = list(dict.fromkeys(cdf_view.implements + (view.implements or [])).keys())
|
|
75
|
+
|
|
76
|
+
for container_ref, container in merged.containers.items():
|
|
77
|
+
if cdf_container := cdf.containers.get(container_ref):
|
|
78
|
+
container.properties = {**cdf_container.properties, **container.properties}
|
|
79
|
+
|
|
80
|
+
return merged
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def fetch_cdf_data_model(cls, client: NeatClient, data_model: RequestSchema) -> Self:
|
|
84
|
+
"""Fetch the latest data model, views, containers, and spaces from CDF based on the provided RequestSchema."""
|
|
85
|
+
now = datetime.now(timezone.utc)
|
|
86
|
+
space_ids = [space.as_reference() for space in data_model.spaces]
|
|
87
|
+
cdf_spaces = client.spaces.retrieve(space_ids)
|
|
88
|
+
|
|
89
|
+
container_refs = [c.as_reference() for c in data_model.containers]
|
|
90
|
+
cdf_containers = client.containers.retrieve(container_refs)
|
|
91
|
+
|
|
92
|
+
view_refs = [v.as_reference() for v in data_model.views]
|
|
93
|
+
cdf_views = client.views.retrieve(view_refs)
|
|
94
|
+
|
|
95
|
+
dm_ref = data_model.data_model.as_reference()
|
|
96
|
+
cdf_data_models = client.data_models.retrieve([dm_ref])
|
|
97
|
+
|
|
98
|
+
nodes = [node_type for view in cdf_views for node_type in view.node_types]
|
|
99
|
+
return cls(
|
|
100
|
+
timestamp=now,
|
|
101
|
+
data_model={dm.as_reference(): dm.as_request() for dm in cdf_data_models},
|
|
102
|
+
views={view.as_reference(): view.as_request() for view in cdf_views},
|
|
103
|
+
containers={container.as_reference(): container.as_request() for container in cdf_containers},
|
|
104
|
+
spaces={space.as_reference(): space.as_request() for space in cdf_spaces},
|
|
105
|
+
node_types={node: node for node in nodes},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def fetch_entire_cdf(cls, client: NeatClient) -> Self:
|
|
110
|
+
"""Fetch the entire data model, views, containers, and spaces from CDF."""
|
|
111
|
+
now = datetime.now(timezone.utc)
|
|
112
|
+
all_views = client.views.list(
|
|
113
|
+
all_versions=True, include_global=True, include_inherited_properties=False, limit=None
|
|
114
|
+
)
|
|
115
|
+
nodes = [node_type for view in all_views for node_type in view.node_types]
|
|
116
|
+
return cls(
|
|
117
|
+
# TODO: spaces and data_models should be update after updating list methods for unlimited no
|
|
118
|
+
spaces={
|
|
119
|
+
response.as_reference(): response.as_request()
|
|
120
|
+
for response in client.spaces.list(include_global=True, limit=1000)
|
|
121
|
+
},
|
|
122
|
+
data_model={
|
|
123
|
+
response.as_reference(): response.as_request()
|
|
124
|
+
for response in client.data_models.list(all_versions=True, include_global=True, limit=1000)
|
|
125
|
+
},
|
|
126
|
+
views={response.as_reference(): response.as_request() for response in all_views},
|
|
127
|
+
containers={
|
|
128
|
+
response.as_reference(): response.as_request()
|
|
129
|
+
for response in client.containers.list(include_global=True, limit=None)
|
|
130
|
+
},
|
|
131
|
+
node_types={node_type: node_type for node_type in nodes},
|
|
132
|
+
timestamp=now,
|
|
133
|
+
)
|