cognite-neat 1.0.28__py3-none-any.whl → 1.0.30__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/_config.py CHANGED
@@ -105,6 +105,9 @@ class AlphaFlagConfig(ConfigModel):
105
105
  default=False,
106
106
  description="If enabled, Neat will run experimental validators that are still in alpha stage.",
107
107
  )
108
+ enable_solution_model_creation: bool = Field(
109
+ default=False, description="If enabled, neat.physical_data_model.create() will be available"
110
+ )
108
111
  enable_cdf_analysis: bool = Field(default=False, description="If enabled, neat.cdf endpoint will be available.")
109
112
 
110
113
  def __setattr__(self, key: str, value: Any) -> None:
@@ -1,10 +1,14 @@
1
+ import math
1
2
  from collections import defaultdict
2
- from itertools import chain
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from itertools import chain, combinations
3
6
  from typing import Literal, TypeAlias, TypeVar
4
7
 
5
8
  import networkx as nx
6
9
  from pyparsing import cached_property
7
10
 
11
+ from cognite.neat._data_model._constants import COGNITE_SPACES
8
12
  from cognite.neat._data_model._snapshot import SchemaSnapshot
9
13
  from cognite.neat._data_model.models.dms._constraints import RequiresConstraintDefinition
10
14
  from cognite.neat._data_model.models.dms._container import ContainerRequest
@@ -41,12 +45,34 @@ ResourceSource = Literal["auto", "merged", "cdf", "both"]
41
45
  _NodeT = TypeVar("_NodeT", ContainerReference, ViewReference)
42
46
 
43
47
 
48
+ class RequiresChangeStatus(Enum):
49
+ """Status of requires constraint changes for a view."""
50
+
51
+ OPTIMAL = "optimal" # Already optimized, no changes needed
52
+ CHANGES_AVAILABLE = "changes_available" # Recommendations available
53
+ UNSOLVABLE = "unsolvable" # Structural issue - can't create connected hierarchy
54
+ NO_MODIFIABLE_CONTAINERS = "no_modifiable_containers" # All containers are immutable
55
+
56
+
57
+ @dataclass
58
+ class RequiresChangesForView:
59
+ """Result of computing requires constraint changes for a view."""
60
+
61
+ to_add: set[tuple[ContainerReference, ContainerReference]]
62
+ to_remove: set[tuple[ContainerReference, ContainerReference]]
63
+ status: RequiresChangeStatus
64
+
65
+
44
66
  class ValidationResources:
45
67
  def __init__(
46
- self, modus_operandi: ModusOperandi, local: SchemaSnapshot, cdf: SchemaSnapshot, limits: SchemaLimits
68
+ self,
69
+ modus_operandi: ModusOperandi,
70
+ local: SchemaSnapshot,
71
+ cdf: SchemaSnapshot,
72
+ limits: SchemaLimits | None = None,
47
73
  ) -> None:
48
74
  self._modus_operandi = modus_operandi
49
- self.limits = limits
75
+ self.limits = limits or SchemaLimits()
50
76
 
51
77
  self.local = local
52
78
  self.cdf = cdf
@@ -385,6 +411,18 @@ class ValidationResources:
385
411
 
386
412
  return connection_end_node_types
387
413
 
414
+ @staticmethod
415
+ def is_explicit_connection(property_: ViewRequestProperty) -> bool:
416
+ """Check if a property is an explicit connection property, meaning end node type is explicitly defined.
417
+
418
+ Args:
419
+ property_: The property to check.
420
+
421
+ Returns:
422
+ True if the property is a connection property, False otherwise.
423
+ """
424
+ return True if property_.source else False
425
+
388
426
  @cached_property
389
427
  def views_by_container(self) -> dict[ContainerReference, set[ViewReference]]:
390
428
  """Get a mapping from containers to the views that use them.
@@ -512,17 +550,84 @@ class ValidationResources:
512
550
  graph.add_node(container_ref)
513
551
 
514
552
  # Add edges for requires constraints from all known containers
515
- for container_ref in graph.nodes():
553
+ for container_ref in list(graph.nodes()):
516
554
  container = self.select_container(container_ref)
517
555
  if not container or not container.constraints:
518
556
  continue
519
- for constraint in container.constraints.values():
557
+ for constraint_id, constraint in container.constraints.items():
520
558
  if not isinstance(constraint, RequiresConstraintDefinition):
521
559
  continue
522
- graph.add_edge(container_ref, constraint.require)
560
+ is_auto = constraint_id.endswith("__auto")
561
+ graph.add_edge(container_ref, constraint.require, is_auto=is_auto)
523
562
 
524
563
  return graph
525
564
 
565
+ @cached_property
566
+ def modifiable_containers(self) -> set[ContainerReference]:
567
+ """Containers whose requires constraints can be modified in this session.
568
+
569
+ A container is modifiable if:
570
+ - It's NOT in a CDF built-in space (CDM, IDM, etc.)
571
+ - It's a user container brought in through the loaded data model scope or view implements chain
572
+ """
573
+ return {container_ref for container_ref in self.merged.containers if container_ref.space not in COGNITE_SPACES}
574
+
575
+ @cached_property
576
+ def immutable_requires_constraint_graph(self) -> nx.DiGraph:
577
+ """Subgraph of requires constraints from non-modifiable (CDM) containers.
578
+
579
+ Used to check reachability via existing immutable constraints.
580
+ """
581
+ return nx.subgraph_view(
582
+ self.requires_constraint_graph,
583
+ filter_edge=lambda src, _: src not in self.modifiable_containers,
584
+ )
585
+
586
+ @cached_property
587
+ def _fixed_constraint_graph(self) -> nx.DiGraph:
588
+ """Graph of all fixed constraints (immutable + user-intentional).
589
+
590
+ Both are "fixed" from the optimizer's perspective - existing paths that can't be changed.
591
+ """
592
+ G = nx.DiGraph()
593
+ G.add_edges_from(self.immutable_requires_constraint_graph.edges())
594
+ G.add_edges_from(self._user_intentional_constraints)
595
+ return G
596
+
597
+ @cached_property
598
+ def _fixed_descendants(self) -> defaultdict[ContainerReference, set[ContainerReference]]:
599
+ """Pre-compute descendants via fixed constraints. Missing keys return empty set."""
600
+ result: defaultdict[ContainerReference, set[ContainerReference]] = defaultdict(set)
601
+ for container in self._fixed_constraint_graph.nodes():
602
+ result[container] = nx.descendants(self._fixed_constraint_graph, container)
603
+ return result
604
+
605
+ @cached_property
606
+ def _existing_requires_edges(self) -> set[tuple[ContainerReference, ContainerReference]]:
607
+ """Cached set of existing requires constraint edges."""
608
+ return set(self.requires_constraint_graph.edges())
609
+
610
+ @cached_property
611
+ def _user_intentional_constraints(self) -> set[tuple[ContainerReference, ContainerReference]]:
612
+ """Constraints that appear to be user-intentional and should not be auto-removed
613
+
614
+ A constraint is user-intentional if:
615
+ 1. The constraint identifier does NOT have '__auto' postfix
616
+ 2. Neither src nor dst is part of a cycle (cyclic constraints are errors)
617
+
618
+ These constraints are preserved even if they're not in the optimal structure, because
619
+ they may be used for data integrity purposes.
620
+ We DON'T consider manual-created constraints as user-intended if they form part of a cycle,
621
+ because that indicates a problem with the data model where we likely can provide a better solution.
622
+ """
623
+ containers_in_cycles = {container for cycle in self.requires_constraint_cycles for container in cycle}
624
+
625
+ return {
626
+ (src, dst)
627
+ for src, dst, data in self.requires_constraint_graph.edges(data=True)
628
+ if not data.get("is_auto", False) and src not in containers_in_cycles and dst not in containers_in_cycles
629
+ }
630
+
526
631
  @staticmethod
527
632
  def forms_directed_path(nodes: set[_NodeT], graph: nx.DiGraph) -> bool:
528
633
  """Check if nodes form an uninterrupted directed path in the graph.
@@ -549,8 +654,14 @@ class ValidationResources:
549
654
  if len(nodes) <= 1:
550
655
  return True
551
656
 
552
- for candidate in nodes:
553
- others = nodes - {candidate}
657
+ # Filter to nodes that are actually in the graph
658
+ nodes_in_graph = {n for n in nodes if n in graph}
659
+ if len(nodes_in_graph) < len(nodes):
660
+ # Some nodes aren't in the graph, so we can't form a complete path
661
+ return False
662
+
663
+ for candidate in nodes_in_graph:
664
+ others = nodes_in_graph - {candidate}
554
665
  if others.issubset(nx.descendants(graph, candidate)):
555
666
  return True
556
667
 
@@ -562,10 +673,331 @@ class ValidationResources:
562
673
  Returns:
563
674
  List of lists, where each list contains the ordered containers involved in forming the requires cycle.
564
675
  """
565
-
566
676
  return self.graph_cycles(self.requires_constraint_graph)
567
677
 
568
678
  @staticmethod
569
679
  def graph_cycles(graph: nx.DiGraph) -> list[list[T_Reference]]:
570
680
  """Returns cycles in the graph otherwise empty list"""
571
681
  return [candidate for candidate in nx.simple_cycles(graph) if len(candidate) > 1]
682
+
683
+ @cached_property
684
+ def optimized_requires_constraint_graph(self) -> nx.DiGraph:
685
+ """Target state of requires constraints after optimizing (MST + fixed constraints)."""
686
+ graph = nx.DiGraph()
687
+ graph.add_edges_from(self.oriented_mst_edges)
688
+ graph.add_edges_from(self._fixed_constraint_graph.edges())
689
+ return graph
690
+
691
+ @cached_property
692
+ def _requires_candidate_graph(self) -> nx.Graph:
693
+ """Build weighted candidate graph for requires constraints.
694
+
695
+ Contains all container pairs that appear together in any view, with:
696
+ - weight: minimum directional weight (for MST computation)
697
+ - preferred_direction: direction with lower weight (for tie-breaking)
698
+
699
+ This graph is used to compute per-view MSTs.
700
+ """
701
+ G = nx.Graph()
702
+
703
+ for view_ref in self.merged.views:
704
+ containers = self.containers_by_view.get(view_ref, set())
705
+ if len(containers) < 2:
706
+ continue # Need at least 2 containers to form a requires constraint
707
+
708
+ # Sort for deterministic preferred_direction when weights are equal
709
+ for src, dst in combinations(sorted(containers, key=str), 2):
710
+ if G.has_edge(src, dst):
711
+ continue # Already added from another view
712
+
713
+ w1 = self._compute_requires_edge_weight(src, dst)
714
+ w2 = self._compute_requires_edge_weight(dst, src)
715
+ direction = (src, dst) if w1 <= w2 else (dst, src)
716
+ weight = min(w1, w2)
717
+
718
+ G.add_edge(src, dst, weight=weight, preferred_direction=direction)
719
+
720
+ return G
721
+
722
+ @cached_property
723
+ def _mst_by_view(self) -> dict[ViewReference, nx.Graph]:
724
+ """Compute per-view MST graphs.
725
+
726
+ Each view gets its own MST over just its containers. This ensures:
727
+ - No routing through containers not in the view
728
+ - Each view gets exactly the edges it needs
729
+ - Voting handles orientation conflicts between views
730
+
731
+ Skips inherently unsolvable views (no immutable anchor + all modifiables are roots).
732
+ """
733
+ if not self._requires_candidate_graph:
734
+ return {}
735
+
736
+ result: dict[ViewReference, nx.Graph] = {}
737
+
738
+ for view_ref in self.merged.views:
739
+ if view_ref in self._views_with_root_conflicts:
740
+ continue
741
+
742
+ containers = self.containers_by_view.get(view_ref, set())
743
+ if len(containers) < 2: # Need at least 2 containers to have a constraint
744
+ continue
745
+ if not containers.intersection(self.modifiable_containers):
746
+ continue
747
+
748
+ subgraph = self._requires_candidate_graph.subgraph(containers)
749
+ if not nx.is_connected(subgraph):
750
+ continue
751
+
752
+ result[view_ref] = nx.minimum_spanning_tree(subgraph, weight="weight")
753
+
754
+ return result
755
+
756
+ @cached_property
757
+ def _root_by_view(self) -> dict[ViewReference, ContainerReference]:
758
+ """Map each view (with 2+ containers) to its most view-specific (root) container.
759
+
760
+ Only includes views with 2+ containers since single-container views
761
+ are trivially satisfied and don't need root direction.
762
+
763
+ Selection criteria (in priority order):
764
+ 1. Fewest views: Containers appearing in fewer views are more "view-specific"
765
+ 2. Has existing constraint: Prefer containers with existing outgoing constraints
766
+ 3. Alphabetical: Deterministic tie-breaker
767
+ """
768
+ result: dict[ViewReference, ContainerReference] = {}
769
+
770
+ for view, containers in self.containers_by_view.items():
771
+ if len(containers) < 2:
772
+ continue
773
+
774
+ modifiable = containers.intersection(self.modifiable_containers)
775
+ if not modifiable:
776
+ continue
777
+
778
+ # Selection priority: fewest views, has existing constraint, alphabetical
779
+ result[view] = min(
780
+ modifiable,
781
+ key=lambda c: (
782
+ len(self.views_by_container.get(c, set())),
783
+ 0 if any((c, other) in self._existing_requires_edges for other in containers) else 1,
784
+ str(c),
785
+ ),
786
+ )
787
+
788
+ return result
789
+
790
+ @cached_property
791
+ def _views_with_root_conflicts(self) -> set[ViewReference]:
792
+ """Views where all modifiable containers are forced roots for other views.
793
+
794
+ A view has root conflicts if:
795
+ 1. It has no immutable containers (no shared CDM anchor)
796
+ 2. All its modifiable containers are already forced roots for other views
797
+
798
+ Such views would require edges between forced roots, causing conflicts.
799
+ """
800
+ forced_roots = set(self._root_by_view.values())
801
+ unsolvable: set[ViewReference] = set()
802
+
803
+ for view, containers in self.containers_by_view.items():
804
+ modifiable = containers & self.modifiable_containers
805
+ immutable = containers - self.modifiable_containers
806
+
807
+ # Need at least 2 modifiable containers to have a conflict
808
+ if len(modifiable) < 2:
809
+ continue
810
+
811
+ # No immutable anchor AND all modifiables are roots elsewhere
812
+ if not immutable and modifiable <= forced_roots:
813
+ unsolvable.add(view)
814
+
815
+ return unsolvable
816
+
817
+ @cached_property
818
+ def oriented_mst_edges(self) -> set[tuple[ContainerReference, ContainerReference]]:
819
+ """Orient per-view MST edges by voting across views.
820
+
821
+ Each view votes for edge orientations based on BFS from its root container.
822
+ Views with only 1 modifiable container use 'inf' vote weight to force
823
+ that container as root.
824
+
825
+ Tie-breaker: preferred_direction from weight function.
826
+
827
+ Returns set of directed (src, dst) tuples.
828
+ """
829
+ edge_votes: dict[tuple[ContainerReference, ContainerReference], float] = defaultdict(float)
830
+ all_edges: set[tuple[ContainerReference, ContainerReference]] = set()
831
+
832
+ # Sort for deterministic iteration (dict order can vary with hash randomization)
833
+ for view in sorted(self._mst_by_view.keys(), key=str):
834
+ mst = self._mst_by_view[view]
835
+ root = self._root_by_view[view] # Always exists for views in _mst_by_view
836
+ containers = self.containers_by_view.get(view, set())
837
+ modifiable_count = len(containers & self.modifiable_containers)
838
+ # Views with only 1 modifiable container have no choice - that container MUST be root
839
+ vote_weight = float("inf") if modifiable_count == 1 else 1.0
840
+
841
+ # BFS from root orients edges away from root (parent → child)
842
+ for parent, child in nx.bfs_edges(mst, root): # type: ignore[func-returns-value]
843
+ if parent in self.modifiable_containers:
844
+ edge_votes[(parent, child)] += vote_weight
845
+
846
+ # Normalize edges to canonical form so votes for same undirected edge are counted together
847
+ for c1, c2 in mst.edges():
848
+ all_edges.add((c1, c2) if str(c1) < str(c2) else (c2, c1))
849
+
850
+ # Pick direction: most votes wins, preferred_direction breaks ties
851
+ oriented: set[tuple[ContainerReference, ContainerReference]] = set()
852
+
853
+ # Sort for deterministic iteration (hash randomization affects set order)
854
+ for c1, c2 in sorted(all_edges, key=lambda e: (str(e[0]), str(e[1]))):
855
+ c1_votes = edge_votes.get((c1, c2), 0)
856
+ c2_votes = edge_votes.get((c2, c1), 0)
857
+
858
+ if c1_votes > c2_votes:
859
+ oriented.add((c1, c2))
860
+ elif c2_votes > c1_votes:
861
+ oriented.add((c2, c1))
862
+ else:
863
+ # Tie-breaker: use preferred_direction from weight function
864
+ oriented.add(self._requires_candidate_graph[c1][c2].get("preferred_direction", (c1, c2)))
865
+
866
+ return oriented
867
+
868
+ @cached_property
869
+ def _transitively_reduced_edges(self) -> set[tuple[ContainerReference, ContainerReference]]:
870
+ """Reduce MST edges to minimal necessary set (remove edges with alternative paths via immutable)."""
871
+ if not self.oriented_mst_edges:
872
+ return set()
873
+
874
+ # Optimal graph = MST + immutable + user-intentional (these provide existing paths)
875
+ optimal = nx.DiGraph()
876
+ optimal.add_edges_from(self.immutable_requires_constraint_graph.edges())
877
+ optimal.add_edges_from(self._user_intentional_constraints)
878
+ optimal.add_edges_from(self.oriented_mst_edges)
879
+
880
+ reduced = nx.transitive_reduction(optimal)
881
+
882
+ # Return MST edges that survive reduction
883
+ return {e for e in reduced.edges() if e in self.oriented_mst_edges}
884
+
885
+ def get_requires_changes_for_view(self, view: ViewReference) -> RequiresChangesForView:
886
+ """Get requires constraint changes needed to optimize a view.
887
+
888
+ Returns a RequiresChangesForView with:
889
+ - to_add: New constraints needed where source is mapped in this view
890
+ - to_remove: Existing constraints that are redundant or wrongly oriented
891
+ - status: The optimization status for this view
892
+ """
893
+ modifiable_containers_in_view = self.containers_by_view.get(view, set()).intersection(
894
+ self.modifiable_containers
895
+ )
896
+ if not modifiable_containers_in_view:
897
+ return RequiresChangesForView(set(), set(), RequiresChangeStatus.NO_MODIFIABLE_CONTAINERS)
898
+
899
+ # Early exit for inherently unsolvable views (no CDM anchor + all modifiables are roots)
900
+ if view in self._views_with_root_conflicts:
901
+ return RequiresChangesForView(
902
+ set[tuple[ContainerReference, ContainerReference]](), set(), RequiresChangeStatus.UNSOLVABLE
903
+ )
904
+
905
+ # Filter edges to those where source is in this view's modifiable containers
906
+ existing_from_view = {
907
+ edge for edge in self._existing_requires_edges if edge[0] in modifiable_containers_in_view
908
+ }
909
+ optimal_for_view = {
910
+ edge for edge in self._transitively_reduced_edges if edge[0] in modifiable_containers_in_view
911
+ }
912
+
913
+ to_add = optimal_for_view - existing_from_view
914
+
915
+ # To remove: existing edges with wrong direction or not in MST (and not needed externally)
916
+ # But NEVER remove user-intentional constraints (manually defined, no __auto postfix)
917
+ to_remove: set[tuple[ContainerReference, ContainerReference]] = set()
918
+ for src, dst in existing_from_view:
919
+ # Skip user-intentional constraints - they were set by the user on purpose
920
+ if (src, dst) in self._user_intentional_constraints:
921
+ continue
922
+ if (dst, src) in self.oriented_mst_edges:
923
+ to_remove.add((src, dst)) # Always remove if opposite direction from optimal solution
924
+ elif (src, dst) not in self.oriented_mst_edges and not (
925
+ self.find_views_mapping_to_containers([src, dst]) - set(self.merged.views)
926
+ ):
927
+ to_remove.add((src, dst)) # Remove if not in optimal solution and not needed by external views
928
+
929
+ # Check solvability in optimized state
930
+ if not self.forms_directed_path(
931
+ self.containers_by_view.get(view, set()), self.optimized_requires_constraint_graph
932
+ ):
933
+ return RequiresChangesForView(set(), set(), RequiresChangeStatus.UNSOLVABLE)
934
+
935
+ if not to_add and not to_remove:
936
+ return RequiresChangesForView(set(), set(), RequiresChangeStatus.OPTIMAL)
937
+
938
+ return RequiresChangesForView(to_add, to_remove, RequiresChangeStatus.CHANGES_AVAILABLE)
939
+
940
+ # ========================================================================
941
+ # REQUIRES CONSTRAINT MST WEIGHT CONSTANTS
942
+ # ========================================================================
943
+ # Weight = TIER + sub_weight.
944
+ #
945
+ # WHY THIS ENCODING:
946
+ # MST algorithm only support scalar weights, but we need a strict priority
947
+ # hierarchy where USER→USER ALWAYS beats USER→EXTERNAL. By using a large
948
+ # gap (1000) between tiers, sub-weights (max ~50) can never cause a lower
949
+ # tier to beat a higher tier.
950
+ #
951
+ # Tiers (explicit priority order):
952
+ # - Tier 1 (USER→USER): Both containers modifiable - always preferred
953
+ # - Tier 2 (USER→EXTERNAL): Target is CDF/CDM - only when needed
954
+ # - Tier ∞ (FORBIDDEN): Invalid edge, forms cycle or source is not modifiable
955
+ #
956
+ # Sub-weights refine ordering WITHIN a tier (shared views, direction, etc).
957
+ # - These have been empirically tuned through trial and error.
958
+ # ========================================================================
959
+
960
+ # Tier base weights (gap of 1000 ensures tier always wins)
961
+ _TIER_USER_TO_USER = 1000
962
+ _TIER_USER_TO_EXTERNAL = 2000
963
+ _TIER_FORBIDDEN = math.inf
964
+
965
+ # Sub-weight adjustments (applied within tier, max ~100)
966
+ _BONUS_SHARED_VIEWS_PER = 5 # Per shared view (max 5 views → 25)
967
+ _BONUS_SHARED_VIEWS_MAX = 25
968
+ _BONUS_COVERAGE_PER = 5 # Per descendant via immutable edges (max 3 → 15)
969
+ _BONUS_COVERAGE_MAX = 15
970
+ _PENALTY_VIEW_COUNT = 10 # When src is in more views than dst
971
+
972
+ # Tie-breaker for deterministic ordering
973
+ _TIE_BREAKER_DIVISOR = 1e9
974
+
975
+ def _compute_requires_edge_weight(self, src: ContainerReference, dst: ContainerReference) -> float:
976
+ """Compute the weight/cost of adding edge src → dst.
977
+
978
+ Returns TIER + sub_weight where tier dominates (gap of 1000).
979
+ Sub-weights refine ordering within a tier based on shared views, direction, coverage.
980
+ """
981
+ # Opposite direction of fixed constraints is forbidden (would conflict with existing path)
982
+ if src in self._fixed_descendants[dst]:
983
+ return self._TIER_FORBIDDEN
984
+
985
+ if src not in self.modifiable_containers:
986
+ return self._TIER_FORBIDDEN
987
+
988
+ src_views = self.views_by_container.get(src, set())
989
+ dst_views = self.views_by_container.get(dst, set())
990
+
991
+ # Sub-weight adjustments
992
+ shared_bonus = min(len(src_views & dst_views) * self._BONUS_SHARED_VIEWS_PER, self._BONUS_SHARED_VIEWS_MAX)
993
+ coverage_bonus = min(len(self._fixed_descendants[dst]) * self._BONUS_COVERAGE_PER, self._BONUS_COVERAGE_MAX)
994
+ view_penalty = self._PENALTY_VIEW_COUNT if len(src_views) > len(dst_views) else 0
995
+
996
+ # Deterministic tie-breaker (very small, only matters when all else is equal)
997
+ edge_str = f"{src.space}:{src.external_id}->{dst.space}:{dst.external_id}"
998
+ tie_breaker = sum(ord(c) for c in edge_str) / self._TIE_BREAKER_DIVISOR
999
+
1000
+ if dst in self.modifiable_containers:
1001
+ return self._TIER_USER_TO_USER - shared_bonus - coverage_bonus + view_penalty + tie_breaker
1002
+
1003
+ return self._TIER_USER_TO_EXTERNAL - shared_bonus - coverage_bonus + tie_breaker
@@ -61,6 +61,7 @@ COGNITE_CONCEPTS: tuple[str, ...] = (
61
61
 
62
62
  COGNITE_SPACES = (
63
63
  CDF_CDM_SPACE,
64
+ "cdf_idm",
64
65
  "cdf_360_image_schema",
65
66
  "cdf_3d_schema",
66
67
  "cdf_apm",
@@ -1,5 +1,5 @@
1
- from ._api_importer import DMSAPIImporter
1
+ from ._api_importer import DMSAPICreator, DMSAPIImporter
2
2
  from ._base import DMSImporter
3
3
  from ._table_importer.importer import DMSTableImporter
4
4
 
5
- __all__ = ["DMSAPIImporter", "DMSImporter", "DMSTableImporter"]
5
+ __all__ = ["DMSAPICreator", "DMSAPIImporter", "DMSImporter", "DMSTableImporter"]
@@ -1,19 +1,30 @@
1
1
  import difflib
2
2
  from pathlib import Path
3
- from typing import Any
3
+ from typing import Any, Literal
4
4
 
5
5
  import yaml
6
6
  from pydantic import ValidationError
7
7
 
8
8
  from cognite.neat._client import NeatClient
9
+ from cognite.neat._data_model._analysis import ValidationResources
10
+ from cognite.neat._data_model._snapshot import SchemaSnapshot
9
11
  from cognite.neat._data_model.importers._base import DMSImporter
10
12
  from cognite.neat._data_model.models.dms import (
11
13
  DataModelReference,
12
14
  RequestSchema,
13
15
  SpaceReference,
14
16
  )
15
- from cognite.neat._exceptions import CDFAPIException, DataModelImportException, FileReadException
16
- from cognite.neat._issues import ModelSyntaxError
17
+ from cognite.neat._data_model.models.dms._data_model import DataModelRequest
18
+ from cognite.neat._data_model.models.dms._references import ViewReference
19
+ from cognite.neat._data_model.models.dms._views import ViewRequest
20
+ from cognite.neat._data_model.models.entities._parser import parse_entities
21
+ from cognite.neat._exceptions import (
22
+ CDFAPIException,
23
+ DataModelCreateException,
24
+ DataModelImportException,
25
+ FileReadException,
26
+ )
27
+ from cognite.neat._issues import ConsistencyError, ModelSyntaxError
17
28
  from cognite.neat._utils.http_client import FailedRequestMessage
18
29
  from cognite.neat._utils.text import humanize_collection
19
30
  from cognite.neat._utils.validation import ValidationContext, humanize_validation_error
@@ -164,3 +175,157 @@ class DMSAPIImporter(DMSImporter):
164
175
  )
165
176
  schema_data["dataModel"] = data_model
166
177
  return schema_data
178
+
179
+
180
+ class DMSAPICreator(DMSImporter):
181
+ """Creates data model from the existing schema components in CDF"""
182
+
183
+ def __init__(
184
+ self,
185
+ cdf_snapshot: SchemaSnapshot,
186
+ space: str,
187
+ external_id: str,
188
+ version: str,
189
+ views: list[str],
190
+ name: str | None = None,
191
+ description: str | None = None,
192
+ kind: Literal["solution"] = "solution",
193
+ ) -> None:
194
+ self._resources = ValidationResources("rebuild", local=cdf_snapshot, cdf=cdf_snapshot)
195
+ self._space = space
196
+ self._external_id = external_id
197
+ self._version = version
198
+ self._name = name
199
+ self._description = description
200
+ self._kind = kind
201
+ self._views = views
202
+
203
+ def to_data_model(self) -> RequestSchema:
204
+ if self._kind == "solution":
205
+ return self._to_solution_model()
206
+ else:
207
+ raise NotImplementedError(f"Data model kind '{self._kind}' is not supported yet.")
208
+
209
+ def _to_solution_model(self) -> RequestSchema:
210
+ """Creates solution data model from provided views"""
211
+
212
+ errors, view_refs = self._parse_view_references(self._views)
213
+
214
+ if not view_refs:
215
+ errors.append(ModelSyntaxError(message="No valid views provided to create the data model."))
216
+
217
+ # Check if all views exist in the cdf snapshot:
218
+ for view in view_refs:
219
+ if not self._resources.select_view(view):
220
+ errors.append(
221
+ ConsistencyError(
222
+ message=f"View '{view}' not found in the provided CDF snapshot. Cannot create data model."
223
+ )
224
+ )
225
+
226
+ if errors:
227
+ raise DataModelCreateException(errors)
228
+
229
+ # now we iterate over the views, expand them and add them to the data model
230
+ expanded_views: list[ViewRequest] = []
231
+
232
+ for view in view_refs:
233
+ expanded_view = self._resources.expand_view_properties(view)
234
+ if not expanded_view:
235
+ raise RuntimeError(f"Failed to expand view '{view}' despite earlier checks. This is a bug.")
236
+
237
+ # make a deep copy to avoid modifying the cached view in resources
238
+ expanded_view = expanded_view.model_copy(deep=True)
239
+
240
+ # remove implements after expansion
241
+ expanded_view.implements = None
242
+
243
+ # set space and version to data model values
244
+ expanded_view.space = self._space
245
+ expanded_view.version = self._version
246
+
247
+ # Connections should be dropped if their source are not part of the expanded view
248
+ properties_to_remove = []
249
+ for id, property_ in expanded_view.properties.items():
250
+ # Currently only supporting explicit connections where source is defined
251
+ if self._resources.is_explicit_connection(property_):
252
+ source = property_.source
253
+ if source not in view_refs:
254
+ properties_to_remove.append(id)
255
+
256
+ # Update source to point to view of solution data model
257
+ else:
258
+ property_.source = ViewReference(
259
+ space=self._space, external_id=source.external_id, version=self._version
260
+ )
261
+
262
+ # Dropping connections which sources will not be part of the data model
263
+ for property_id in properties_to_remove:
264
+ expanded_view.properties.pop(property_id)
265
+
266
+ expanded_views.append(expanded_view)
267
+
268
+ data_model = DataModelRequest(
269
+ space=self._space,
270
+ externalId=self._external_id,
271
+ version=self._version,
272
+ name=self._name,
273
+ description=self._description,
274
+ views=[view.as_reference() for view in expanded_views],
275
+ )
276
+
277
+ return RequestSchema(dataModel=data_model, views=expanded_views)
278
+
279
+ @classmethod
280
+ def _parse_view_references(
281
+ cls, views: list[str]
282
+ ) -> tuple[list[ModelSyntaxError | ConsistencyError], list[ViewReference]]:
283
+ """Parses list of view strings in to list of view references
284
+
285
+ Args:
286
+ views : list of view references in a short string format
287
+
288
+ Returns:
289
+ Tuple of errors and view references
290
+ """
291
+ view_references: list[ViewReference] = []
292
+ errors: list[ModelSyntaxError | ConsistencyError] = []
293
+
294
+ for view in views:
295
+ try:
296
+ entity = parse_entities(view)
297
+ if not entity:
298
+ errors.append(ModelSyntaxError(message=f"Invalid view reference '{view}': Could not parse entity."))
299
+ continue
300
+
301
+ if len(entity) != 1:
302
+ errors.append(
303
+ ModelSyntaxError(message=f"Invalid view reference '{view}': Expected a single view definition.")
304
+ )
305
+ continue
306
+
307
+ if "version" not in entity[0].properties:
308
+ errors.append(
309
+ ModelSyntaxError(message=f"Invalid view reference '{view}': Missing 'version' property.")
310
+ )
311
+ continue
312
+
313
+ try:
314
+ view_ref = ViewReference(
315
+ space=entity[0].prefix,
316
+ external_id=entity[0].suffix,
317
+ version=str(entity[0].properties["version"]),
318
+ )
319
+ view_references.append(view_ref)
320
+ except ValidationError as e:
321
+ humanized = [humanize_validation_error(error) for error in e.errors()]
322
+ errors.append(
323
+ ModelSyntaxError(
324
+ message=f"Invalid view reference '{view}', cannot parse it: " + ", ".join(humanized)
325
+ )
326
+ )
327
+
328
+ except ValueError as e:
329
+ errors.append(ModelSyntaxError(message=f"Invalid view reference '{view}', cannot parse it: {e}"))
330
+
331
+ return errors, view_references
@@ -37,6 +37,11 @@ from ._limits import (
37
37
  ViewPropertyCountIsOutOfLimits,
38
38
  )
39
39
  from ._orchestrator import DmsDataModelValidation
40
+ from ._performance import (
41
+ MissingRequiresConstraint,
42
+ SuboptimalRequiresConstraint,
43
+ UnresolvableQueryPerformance,
44
+ )
40
45
  from ._views import ImplementedViewNotExisting, ViewToContainerMappingNotPossible
41
46
 
42
47
  __all__ = [
@@ -54,6 +59,7 @@ __all__ = [
54
59
  "ExternalContainerDoesNotExist",
55
60
  "ExternalContainerPropertyDoesNotExist",
56
61
  "ImplementedViewNotExisting",
62
+ "MissingRequiresConstraint",
57
63
  "RequiredContainerDoesNotExist",
58
64
  "RequiresConstraintCycle",
59
65
  "ReverseConnectionContainerMissing",
@@ -65,6 +71,8 @@ __all__ = [
65
71
  "ReverseConnectionSourceViewMissing",
66
72
  "ReverseConnectionTargetMismatch",
67
73
  "ReverseConnectionTargetMissing",
74
+ "SuboptimalRequiresConstraint",
75
+ "UnresolvableQueryPerformance",
68
76
  "ViewContainerCountIsOutOfLimits",
69
77
  "ViewImplementsCountIsOutOfLimits",
70
78
  "ViewMissingDescription",
@@ -223,12 +223,26 @@ class RequiresConstraintCycle(DataModelValidator):
223
223
 
224
224
  def run(self) -> list[ConsistencyError]:
225
225
  errors: list[ConsistencyError] = []
226
+ optimal_edges = self.validation_resources.oriented_mst_edges
226
227
 
227
228
  for cycle in self.validation_resources.requires_constraint_cycles:
228
229
  cycle_str = " -> ".join(str(c) for c in cycle) + f" -> {cycle[0]!s}"
230
+
231
+ # Find edges in cycle that are NOT in optimal structure (these should be removed)
232
+ edges_to_remove = []
233
+ for i, container in enumerate(cycle):
234
+ next_container = cycle[(i + 1) % len(cycle)]
235
+ edge = (container, next_container)
236
+ if edge not in optimal_edges:
237
+ edges_to_remove.append(f"{container} -> {next_container}")
238
+
239
+ message = f"Requires constraints form a cycle: {cycle_str}"
240
+ if edges_to_remove:
241
+ message += f". Recommended removal: {', '.join(edges_to_remove)} (not in optimal structure)"
242
+
229
243
  errors.append(
230
244
  ConsistencyError(
231
- message=f"Requires constraints form a cycle: {cycle_str}",
245
+ message=message,
232
246
  fix="Remove one of the requires constraints to break the cycle",
233
247
  code=self.code,
234
248
  )
@@ -0,0 +1,194 @@
1
+ """Validators for checking performance-related aspects of the data model."""
2
+
3
+ from cognite.neat._data_model._analysis import RequiresChangeStatus
4
+ from cognite.neat._data_model._constants import COGNITE_SPACES
5
+ from cognite.neat._data_model.validation.dms._base import DataModelValidator
6
+ from cognite.neat._issues import Recommendation
7
+
8
+ BASE_CODE = "NEAT-DMS-PERFORMANCE"
9
+
10
+
11
+ class MissingRequiresConstraint(DataModelValidator):
12
+ """
13
+ Recommends adding requires constraints to optimize query performance.
14
+
15
+ ## What it does
16
+ Identifies views that is mapping to containers where adding a requires constraint,
17
+ would improve query performance. The recommendation message indicates whether the
18
+ change is "safe" or requires attention to potential ingestion dependencies.
19
+
20
+ ## Why is this important?
21
+ Views without proper requires constraints may have poor query performance.
22
+ Adding requires constraints enables queries to perform under-the-hood optimizations.
23
+
24
+ ## Example
25
+ View `Valve` is mapping to both containers `Valve` and `CogniteEquipment`.
26
+ A `requires` constraint from `Valve` to `CogniteEquipment` is likely needed
27
+ to enable efficient query performance.
28
+ """
29
+
30
+ code = f"{BASE_CODE}-001"
31
+ issue_type = Recommendation
32
+ alpha = True
33
+
34
+ def run(self) -> list[Recommendation]:
35
+ recommendations: list[Recommendation] = []
36
+
37
+ for view_ref in self.validation_resources.merged.views:
38
+ changes = self.validation_resources.get_requires_changes_for_view(view_ref)
39
+
40
+ if changes.status != RequiresChangeStatus.CHANGES_AVAILABLE:
41
+ continue
42
+
43
+ for src, dst in changes.to_add:
44
+ src_views = self.validation_resources.views_by_container.get(src, set())
45
+ other_views_with_src = src_views - {view_ref}
46
+ views_impacted_by_change = self.validation_resources.find_views_mapping_to_containers([src, dst])
47
+
48
+ # Check if this is a "safe" recommendation (no cross-view dependencies)
49
+ is_safe = not other_views_with_src or view_ref in views_impacted_by_change
50
+
51
+ message = (
52
+ f"View '{view_ref!s}' is not optimized for querying. "
53
+ f"Add a 'requires' constraint from '{src!s}' to '{dst!s}'."
54
+ )
55
+ if not is_safe:
56
+ # Find a superset view to suggest for ingestion
57
+ merged_views = set(self.validation_resources.merged.views)
58
+ superset_views = views_impacted_by_change & merged_views
59
+ view_example = min(superset_views, key=str) if superset_views else None
60
+ message += (
61
+ " Note: this causes an ingestion dependency for this view, "
62
+ " if you will be using this view to ingest instances, you will "
63
+ f"need to populate these instances into '{dst!s}' first"
64
+ + (f", for example through view '{view_example!s}'." if view_example else ".")
65
+ )
66
+
67
+ recommendations.append(
68
+ Recommendation(
69
+ message=message,
70
+ fix="Add requires constraint between the containers",
71
+ code=self.code,
72
+ )
73
+ )
74
+
75
+ return recommendations
76
+
77
+
78
+ class SuboptimalRequiresConstraint(DataModelValidator):
79
+ """
80
+ Recommends removing requires constraints that are not optimal.
81
+
82
+ ## What it does
83
+ Identifies existing requires constraints that are not optimal for querying purposes,
84
+ as they are either redundant or create unnecessary ingestion dependencies when all
85
+ other optimal constraints are applied. These constraints can be safely removed
86
+ without affecting query performance.
87
+
88
+ ## Why is this important?
89
+ Unnecessary requires constraints can:
90
+ - Create unnecessary ingestion dependencies
91
+ - Cause invalid requires constraint cycles if optimal constraints are applied
92
+
93
+ ## Example
94
+ Container `Tag` has a `requires` constraint to `Pump`, but NEAT determined that
95
+ `Pump → Tag` is more optimal. The existing `Tag → Pump` constraint should then
96
+ be removed when applying all optimal constraints.
97
+ """
98
+
99
+ code = f"{BASE_CODE}-002"
100
+ issue_type = Recommendation
101
+ alpha = True
102
+
103
+ def run(self) -> list[Recommendation]:
104
+ recommendations: list[Recommendation] = []
105
+
106
+ for view_ref in self.validation_resources.merged.views:
107
+ changes = self.validation_resources.get_requires_changes_for_view(view_ref)
108
+
109
+ if changes.status != RequiresChangeStatus.CHANGES_AVAILABLE:
110
+ continue
111
+
112
+ for src, dst in changes.to_remove:
113
+ recommendations.append(
114
+ Recommendation(
115
+ message=(
116
+ f"View '{view_ref!s}' has a requires constraint '{src!s}' -> '{dst!s}' "
117
+ "that is not part of the optimal structure. Consider removing it."
118
+ ),
119
+ fix="Remove the unnecessary requires constraint",
120
+ code=self.code,
121
+ )
122
+ )
123
+
124
+ return recommendations
125
+
126
+
127
+ class UnresolvableQueryPerformance(DataModelValidator):
128
+ """
129
+ Identifies views with query performance issues that cannot be resolved.
130
+ This is likely to be caused by unintended modeling choices.
131
+
132
+ ## What it does
133
+ Detects views where no valid requires constraint solution exists:
134
+
135
+ 1. **View maps only to CDF built-in containers**:
136
+ Since CDF containers cannot be modified, no requires can be added.
137
+
138
+ 2. **No valid solution**:
139
+ This view is causing issues when optimizing requires constraints
140
+ for other views, due to its structure (mapping non-overlapping containers)
141
+
142
+ ## Why is this important?
143
+ These views will have suboptimal query performance that CANNOT be fixed by
144
+ adding or removing requires constraints. The only solutions require restructuring:
145
+ - Add a view-specific container that requires all the other containers in the view
146
+ - Restructure the view to use different containers
147
+
148
+ ## Example
149
+ View `MultipleEquipments` maps only to containers `Valve` and `InstrumentEquipment`.
150
+ The optimal constraints are `Valve → CogniteEquipment` and `InstrumentEquipment → CogniteEquipment`
151
+ due to other views needing these constraints to optimize their query performance.
152
+ This means however, that neither Valve nor InstrumentEquipment can reach each other without
153
+ creating complex ingestion dependencies. The view needs a new container or restructuring.
154
+ """
155
+
156
+ code = f"{BASE_CODE}-003"
157
+ issue_type = Recommendation
158
+ alpha = True
159
+
160
+ def run(self) -> list[Recommendation]:
161
+ recommendations: list[Recommendation] = []
162
+
163
+ for view_ref in self.validation_resources.merged.views:
164
+ if view_ref.space in COGNITE_SPACES:
165
+ continue
166
+
167
+ changes = self.validation_resources.get_requires_changes_for_view(view_ref)
168
+
169
+ if changes.status == RequiresChangeStatus.NO_MODIFIABLE_CONTAINERS:
170
+ recommendations.append(
171
+ Recommendation(
172
+ message=(
173
+ f"View '{view_ref!s}' has poor query performance. "
174
+ "It maps only to CDF built-in containers which cannot have requires constraints. "
175
+ "Consider adding a view-specific container that requires the others."
176
+ ),
177
+ fix="Add a container that requires the others, or restructure the view",
178
+ code=self.code,
179
+ )
180
+ )
181
+ elif changes.status == RequiresChangeStatus.UNSOLVABLE:
182
+ recommendations.append(
183
+ Recommendation(
184
+ message=(
185
+ f"View '{view_ref!s}' has poor query performance. "
186
+ "No valid requires constraint solution was found. "
187
+ "Consider adding a view-specific container that requires the others."
188
+ ),
189
+ fix="Add a container that requires the others, or restructure the view",
190
+ code=self.code,
191
+ )
192
+ )
193
+
194
+ return recommendations
@@ -1,6 +1,8 @@
1
1
  from abc import ABC
2
2
  from typing import TYPE_CHECKING
3
3
 
4
+ from cognite.neat._issues import ConsistencyError
5
+
4
6
  if TYPE_CHECKING:
5
7
  from cognite.neat._issues import ModelSyntaxError
6
8
  from cognite.neat._utils.http_client import HTTPMessage
@@ -23,6 +25,17 @@ class DataModelImportException(NeatException):
23
25
  return f"Model import failed with {len(self.errors)} errors: " + "; ".join(map(str, self.errors))
24
26
 
25
27
 
28
+ class DataModelCreateException(NeatException):
29
+ """Raised when there is an error creating a data model."""
30
+
31
+ def __init__(self, errors: "list[ModelSyntaxError | ConsistencyError]") -> None:
32
+ super().__init__(errors)
33
+ self.errors = errors
34
+
35
+ def __str__(self) -> str:
36
+ return f"Model creation failed with {len(self.errors)} errors: " + "; ".join(map(str, self.errors))
37
+
38
+
26
39
  class CDFAPIException(NeatException):
27
40
  """Raised when there is an error in an API call."""
28
41
 
@@ -1,3 +1,4 @@
1
+ from types import MethodType
1
2
  from typing import Any, Literal
2
3
 
3
4
  from cognite.neat._client import NeatClient
@@ -13,7 +14,7 @@ from cognite.neat._data_model.exporters import (
13
14
  DMSTableYamlExporter,
14
15
  )
15
16
  from cognite.neat._data_model.exporters._table_exporter.workbook import WorkbookOptions
16
- from cognite.neat._data_model.importers import DMSAPIImporter, DMSImporter, DMSTableImporter
17
+ from cognite.neat._data_model.importers import DMSAPICreator, DMSAPIImporter, DMSImporter, DMSTableImporter
17
18
  from cognite.neat._data_model.models.dms import DataModelReference
18
19
  from cognite.neat._data_model.validation.dms import DmsDataModelValidation
19
20
  from cognite.neat._exceptions import UserInputError
@@ -24,6 +25,7 @@ from cognite.neat._utils._reader import NeatReader
24
25
  from ._wrappers import session_wrapper
25
26
 
26
27
 
28
+ @session_wrapper
27
29
  class PhysicalDataModel:
28
30
  """Read from a data source into NeatSession graph store."""
29
31
 
@@ -34,6 +36,10 @@ class PhysicalDataModel:
34
36
  self.read = ReadPhysicalDataModel(self._store, self._client, self._config)
35
37
  self.write = WritePhysicalDataModel(self._store, self._client, self._config)
36
38
 
39
+ # attach alpha methods
40
+ if self._config.alpha.enable_solution_model_creation:
41
+ self.create = MethodType(create, self) # type: ignore[attr-defined]
42
+
37
43
  def _repr_html_(self) -> str:
38
44
  if not isinstance(self._store.state, PhysicalState):
39
45
  return "No physical data model. Get started by reading physical data model <em>.physica_data_mode.read</em>"
@@ -292,3 +298,51 @@ class WritePhysicalDataModel:
292
298
  )
293
299
  on_success = SchemaDeployer(self._client, options)
294
300
  return self._store.write_physical(writer, on_success)
301
+
302
+
303
+ def create(
304
+ self: PhysicalDataModel,
305
+ space: str,
306
+ external_id: str,
307
+ version: str,
308
+ views: list[str],
309
+ name: str | None = None,
310
+ description: str | None = None,
311
+ kind: Literal["solution"] = "solution",
312
+ ) -> None:
313
+ """Create a solution data model in Neat from CDF views.
314
+
315
+ Args:
316
+ space (str): The schema space of the data model.
317
+ external_id (str): The external id of the data model.
318
+ version (str): The version of the data model.
319
+ views (list[str]): List of view external ids to include in the data model in the short string format
320
+ space:external_id(version=version)
321
+ name (str | None): The name of the data model. If None, the name will be fetched from CDF.
322
+ description (str | None): The description of the data model. If None, the description will be fetched from CDF.
323
+ kind (Literal["solution"]): The kind of the data model. Currently, only "solution" is supported.
324
+ """
325
+
326
+ if not self._store.cdf_snapshot.data_model:
327
+ raise ValueError("There are no data models in CDF. Cannot create solution model.")
328
+
329
+ creator = DMSAPICreator(
330
+ space=space,
331
+ external_id=external_id,
332
+ version=version,
333
+ views=views,
334
+ name=name,
335
+ description=description,
336
+ kind=kind,
337
+ cdf_snapshot=self._store.cdf_snapshot,
338
+ )
339
+
340
+ on_success = DmsDataModelValidation(
341
+ modus_operandi=self._config.modeling.mode,
342
+ cdf_snapshot=self._store.cdf_snapshot,
343
+ limits=self._store.cdf_limits,
344
+ can_run_validator=self._config.validation.can_run_validator,
345
+ enable_alpha_validators=self._config.alpha.enable_experimental_validators,
346
+ )
347
+
348
+ return self._store.read_physical(creator, on_success)
@@ -96,6 +96,24 @@ def session_wrapper(cls: type[T_Class]) -> type[T_Class]:
96
96
  if callable(attr):
97
97
  # Replace the original method with wrapped version
98
98
  setattr(cls, attr_name, _handle_method_call(attr))
99
-
100
- # Return the modified class
99
+ # Intercept __init__ to wrap any methods added via setattr
100
+ original_init = cls.__init__
101
+
102
+ @wraps(original_init)
103
+ def pick_alpha_methods(self: HasStore, *args: Any, **kwargs: Any) -> Any:
104
+ """This method wraps any instance methods added during __init__. which is the case for alpha methods"""
105
+ original_init(self, *args, **kwargs)
106
+ # Wrap any instance methods added during init
107
+ for attr_name in dir(self):
108
+ if not attr_name.startswith("_"):
109
+ attr = getattr(self, attr_name, None)
110
+ # Check if it's an instance method (not from the class)
111
+ if callable(attr) and attr_name not in vars(self.__class__):
112
+ # Wrap and set on the instance
113
+ wrapped = (
114
+ _handle_method_call(attr.__func__) if hasattr(attr, "__func__") else _handle_method_call(attr)
115
+ )
116
+ setattr(self, attr_name, wrapped.__get__(self, type(self)))
117
+
118
+ cls.__init__ = pick_alpha_methods # type: ignore[assignment]
101
119
  return cls
@@ -15,7 +15,7 @@ from cognite.neat._data_model.exporters._table_exporter.exporter import DMSTable
15
15
  from cognite.neat._data_model.importers import DMSImporter, DMSTableImporter
16
16
  from cognite.neat._data_model.models.dms import RequestSchema as PhysicalDataModel
17
17
  from cognite.neat._data_model.models.dms._limits import SchemaLimits
18
- from cognite.neat._exceptions import DataModelImportException
18
+ from cognite.neat._exceptions import DataModelCreateException, DataModelImportException
19
19
  from cognite.neat._issues import IssueList
20
20
  from cognite.neat._state_machine._states import EmptyState, PhysicalState, State
21
21
  from cognite.neat._utils.text import NEWLINE
@@ -179,6 +179,9 @@ class NeatStore:
179
179
  except DataModelImportException as e:
180
180
  errors.extend(e.errors)
181
181
 
182
+ except DataModelCreateException as e:
183
+ errors.extend(e.errors)
184
+
182
185
  # these are all other errors, such as missing file, wrong format, etc.
183
186
  except Exception as e:
184
187
  raise e
cognite/neat/_version.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "1.0.28"
1
+ __version__ = "1.0.30"
2
2
  __engine__ = "^2.0.4"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cognite-neat
3
- Version: 1.0.28
3
+ Version: 1.0.30
4
4
  Summary: Knowledge graph transformation
5
5
  Author: Nikola Vasiljevic, Anders Albert
6
6
  Author-email: Nikola Vasiljevic <nikola.vasiljevic@cognite.com>, Anders Albert <anders.albert@cognite.com>
@@ -15,10 +15,10 @@ cognite/neat/_client/init/main.py,sha256=D-9bsor07T84iFnTZLnhIFA1Xey9zsooHaDhXVe
15
15
  cognite/neat/_client/spaces_api.py,sha256=yb26ju3sKbVgiduMEbN4LDGan6vsC3z_JaW3p4FUdr0,2929
16
16
  cognite/neat/_client/statistics_api.py,sha256=5meeh0v5mxC2SMB7xGdOwMh4KkaX3DtpUjbGEPhNBJo,913
17
17
  cognite/neat/_client/views_api.py,sha256=YMaw7IaxU4gmixpd_t1u9JK9BHfNerf5DMNinGPCAa0,3692
18
- cognite/neat/_config.py,sha256=RVVV6qTkFlS88lJKShIcz9pcCV4OlCxFW8dUbSi50-4,10003
18
+ cognite/neat/_config.py,sha256=IJS_R-86M4SLxdo644LZ0dE9h0jg2XBL1A4QKWJj0TQ,10160
19
19
  cognite/neat/_data_model/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
- cognite/neat/_data_model/_analysis.py,sha256=dN-udKm_5oD3217O4B_QIps2Hx4v50-Pu2fR0bQNQg0,23504
21
- cognite/neat/_data_model/_constants.py,sha256=E2axzdYjsIy7lTHsjW91wsv6r-pUwko8g6K8C_oRnxk,1707
20
+ cognite/neat/_data_model/_analysis.py,sha256=Z5HQ1ExB58Y_2W79ovOXbhyHQiAuB0S86117ZYMMjpg,43285
21
+ cognite/neat/_data_model/_constants.py,sha256=h6FaREqE2KG7hy2ZQVDq-3_yjVjlpSG6Usz2kadIUIs,1722
22
22
  cognite/neat/_data_model/_identifiers.py,sha256=lDLvMvYDgRNFgk5GmxWzOUunG7M3synAciNjzJI0m_o,1913
23
23
  cognite/neat/_data_model/_shared.py,sha256=H0gFqa8tKFNWuvdat5jL6OwySjCw3aQkLPY3wtb9Wrw,1302
24
24
  cognite/neat/_data_model/_snapshot.py,sha256=JBaKmL0Tmprz59SZ1JeB49BPMB8Hqa-OAOt0Bai8cw4,6305
@@ -37,8 +37,8 @@ cognite/neat/_data_model/exporters/_table_exporter/__init__.py,sha256=47DEQpj8HB
37
37
  cognite/neat/_data_model/exporters/_table_exporter/exporter.py,sha256=4BPu_Chtjh1EyOaKbThXYohsqllVOkCbSoNekNZuBXc,5159
38
38
  cognite/neat/_data_model/exporters/_table_exporter/workbook.py,sha256=1Afk1WqeNe9tiNeSAm0HrF8jTQ1kTbIv1D9hMztKwO8,18482
39
39
  cognite/neat/_data_model/exporters/_table_exporter/writer.py,sha256=k1gPrI8OdS75x3ncLn27Z-wnWgkOJ_eiCFnfp-rgdyY,21748
40
- cognite/neat/_data_model/importers/__init__.py,sha256=dHnKnC_AXk42z6wzEHK15dxIOh8xSEkuUf_AFRZls0E,193
41
- cognite/neat/_data_model/importers/_api_importer.py,sha256=H8Ow3Tt7utuAuBhC6s7yWvhGqunHAtE0r0XRsVAr6IE,7280
40
+ cognite/neat/_data_model/importers/__init__.py,sha256=35MasidLXvQyi7aL7eyNbY0hRGZEGQOHjCSw3lHkBdg,225
41
+ cognite/neat/_data_model/importers/_api_importer.py,sha256=jkSw74kwKCFC1RvEr0DOn34dhTlcqbiBTcWT5vBuXYk,13838
42
42
  cognite/neat/_data_model/importers/_base.py,sha256=NRB0FcEBj4GaethU68nRffBfTedBBA866A3zfJNfmiQ,433
43
43
  cognite/neat/_data_model/importers/_table_importer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
44
44
  cognite/neat/_data_model/importers/_table_importer/data_classes.py,sha256=_Hfub4GZNxiq01JcxijjeNFaecJXQmmMNoFe7apo0iM,11466
@@ -76,16 +76,17 @@ cognite/neat/_data_model/models/entities/_data_types.py,sha256=DfdEWGek7gODro-_0
76
76
  cognite/neat/_data_model/models/entities/_identifiers.py,sha256=a7ojJKY1ErZgUANHscEwkctX4RJ7bWEEWOQt5g5Tsdk,1915
77
77
  cognite/neat/_data_model/models/entities/_parser.py,sha256=zef_pSDZYMZrJl4IKreFDR577KutfhtN1xpH3Ayjt2o,7669
78
78
  cognite/neat/_data_model/validation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
79
- cognite/neat/_data_model/validation/dms/__init__.py,sha256=STb0mYmyD-E6CfhjaXNOgWM1LvYSm59m6-Khu9cEhNI,2702
79
+ cognite/neat/_data_model/validation/dms/__init__.py,sha256=Z6P8TTXZR1KIJreDieRVTcJVvovdZXCLXRbzm86UojI,2936
80
80
  cognite/neat/_data_model/validation/dms/_ai_readiness.py,sha256=bffMQJ5pqumU5P3KaEdQP67OO5eMKqzN2BAWbUjG6KE,16143
81
81
  cognite/neat/_data_model/validation/dms/_base.py,sha256=G_gMPTgKwyBW62UcCkKIBVHWp9ufAZPJ2p7o69_dJI0,820
82
82
  cognite/neat/_data_model/validation/dms/_connections.py,sha256=-kUXf2_3V50ckxwXRwJoTHsKkS5zxiBKkkkHg8Dm4WI,30353
83
83
  cognite/neat/_data_model/validation/dms/_consistency.py,sha256=IKSUoRQfQQcsymviESW9VuTFX7jsZMXfsObeZHPdov4,2435
84
- cognite/neat/_data_model/validation/dms/_containers.py,sha256=8pVnmeX6G9tQaGzzwRB_40y7TYUm4guaNbRiFgoGILU,9895
84
+ cognite/neat/_data_model/validation/dms/_containers.py,sha256=n2L8HiJLylHIkzGi1veGb2QLVEFZuLlOwws77mxm0Fw,10552
85
85
  cognite/neat/_data_model/validation/dms/_limits.py,sha256=rAIh54DJBPi3J7d7eD-jMdldZS8R2vlkQ5MD9RxNsrI,14830
86
86
  cognite/neat/_data_model/validation/dms/_orchestrator.py,sha256=qiuUSUmNhekFyBARUUO2yhG-X9AeU_LL49UrJ65JXFA,2964
87
+ cognite/neat/_data_model/validation/dms/_performance.py,sha256=hq4neZfoa0EKdYjhiQpugP9jrhSpTsn8_VJRncTj5Bk,8500
87
88
  cognite/neat/_data_model/validation/dms/_views.py,sha256=pRdnj5ZBnHNnbLKleXGbipteGma8_l5AYsDIfqgAil4,6345
88
- cognite/neat/_exceptions.py,sha256=mO19TEecZYDNqSvzuc6JmCLFQ70eniT1-Gb0AEbgbzE,2090
89
+ cognite/neat/_exceptions.py,sha256=hOjPL1vFNNAZzqAHFB9l9ek-XJEBKcqiaPk0onwLPns,2540
89
90
  cognite/neat/_issues.py,sha256=wH1mnkrpBsHUkQMGUHFLUIQWQlfJ_qMfdF7q0d9wNhY,1871
90
91
  cognite/neat/_session/__init__.py,sha256=owqW5Mml2DSZx1AvPvwNRTBngfhBNrQ6EH-7CKL7Jp0,61
91
92
  cognite/neat/_session/_cdf.py,sha256=Ps49pc2tKriImpDkcIABnCgrfoX-L3QGmnXK8ceJ1Lc,1365
@@ -104,7 +105,7 @@ cognite/neat/_session/_html/templates/deployment.html,sha256=aLDXMbF3pcSqnCpUYVG
104
105
  cognite/neat/_session/_html/templates/issues.html,sha256=zjhkJcPK0hMp_ZKJ9RCf88tuZxQyTYRPxzpqx33Nkt0,1661
105
106
  cognite/neat/_session/_html/templates/statistics.html,sha256=C-ghoT8xzBehAJLOonNzCB418LW-FA60MaZBHlW1D5c,968
106
107
  cognite/neat/_session/_issues.py,sha256=E8UQeSJURg2dm4MF1pfD9dp-heSRT7pgQZgKlD1-FGs,2723
107
- cognite/neat/_session/_physical.py,sha256=e3i9xuLa4bMOa-mJxbf4AyLZ2UnPOPh05P-E7E0lRHY,12449
108
+ cognite/neat/_session/_physical.py,sha256=uzgyNfchv5axhDMjdGB6hnpbvT_QfXKmI6zhwbsFW-o,14448
108
109
  cognite/neat/_session/_result/__init__.py,sha256=8A0BKgsqnjxkiHUlCpHBNl3mrFWtyjaWYnh0jssE6QU,50
109
110
  cognite/neat/_session/_result/_deployment/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
110
111
  cognite/neat/_session/_result/_deployment/_physical/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -117,13 +118,13 @@ cognite/neat/_session/_usage_analytics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JC
117
118
  cognite/neat/_session/_usage_analytics/_collector.py,sha256=8yVfzt8KFfZ-ldkVjDWazuQbs45Q3r6vWKcZEwU8i18,4734
118
119
  cognite/neat/_session/_usage_analytics/_constants.py,sha256=-tVdYrCTMKfuMlbO7AlzC29Nug41ug6uuX9DFuihpJg,561
119
120
  cognite/neat/_session/_usage_analytics/_storage.py,sha256=w3mUvmPysww6vM3PZBjg6jzNEsDISl7FJ1j19LNs26E,7779
120
- cognite/neat/_session/_wrappers.py,sha256=28RjNsRs6317Ml-ZHFytBT7c8z_aMQ-adkQVtrzEC6U,4412
121
+ cognite/neat/_session/_wrappers.py,sha256=3GADffOVyGryXknnN88hKhwTHvtZfeFBYpCROuXyGXY,5447
121
122
  cognite/neat/_state_machine/__init__.py,sha256=wrtQUHETiLzYM0pFo7JC6pJCiXetHADQbyMu8pU8rQU,195
122
123
  cognite/neat/_state_machine/_base.py,sha256=-ZpeAhM6l6N6W70dET25tAzOxaaK5aa474eabwZVzjA,1112
123
124
  cognite/neat/_state_machine/_states.py,sha256=nmj4SmunpDYcBsNx8A284xnXGS43wuUuWpMMORha2DE,1170
124
125
  cognite/neat/_store/__init__.py,sha256=TvM9CcFbtOSrxydPAuJi6Bv_iiGard1Mxfx42ZFoTl0,55
125
126
  cognite/neat/_store/_provenance.py,sha256=1zzRDWjR9twZu2jVyIG3UdYdIXtQKJ7uF8a0hV7LEuA,3368
126
- cognite/neat/_store/_store.py,sha256=jtJPBQ8PBG0UlgzSJJpzOIRkC2Np3fHNtV4omRC6H5A,9629
127
+ cognite/neat/_store/_store.py,sha256=3mfw5zFL2nLad57gHbriDnwd5WMy4qoUKKinK5k3xwA,9738
127
128
  cognite/neat/_utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
128
129
  cognite/neat/_utils/_reader.py,sha256=pBAwGK_UMd-mGnAP3cXXj3SrORocR3lFKxn-WaVjqsY,5367
129
130
  cognite/neat/_utils/auxiliary.py,sha256=YQMpqCxccex_slmLYrR5icVX9aeLbD793ou7IrbNTFs,1654
@@ -327,9 +328,9 @@ cognite/neat/_v0/session/_template.py,sha256=BNcvrW5y7LWzRM1XFxZkfR1Nc7e8UgjBClH
327
328
  cognite/neat/_v0/session/_to.py,sha256=AnsRSDDdfFyYwSgi0Z-904X7WdLtPfLlR0x1xsu_jAo,19447
328
329
  cognite/neat/_v0/session/_wizard.py,sha256=baPJgXAAF3d1bn4nbIzon1gWfJOeS5T43UXRDJEnD3c,1490
329
330
  cognite/neat/_v0/session/exceptions.py,sha256=jv52D-SjxGfgqaHR8vnpzo0SOJETIuwbyffSWAxSDJw,3495
330
- cognite/neat/_version.py,sha256=D8wnGH1r0wYJFo06cmkFVRH6v-0euoV5JeDvOyoYFfE,45
331
+ cognite/neat/_version.py,sha256=6cfMmDsIfl6IMAFwlEU9DPRkWwKrndMYQPZ5zAZvnuk,45
331
332
  cognite/neat/legacy.py,sha256=DMFeLCSBLT2enk-nm1KfX1rKR2DQDpxY-w6ThY0y9c8,421
332
333
  cognite/neat/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
333
- cognite_neat-1.0.28.dist-info/WHEEL,sha256=XV0cjMrO7zXhVAIyyc8aFf1VjZ33Fen4IiJk5zFlC3g,80
334
- cognite_neat-1.0.28.dist-info/METADATA,sha256=i_1J8DaOFH5SdieuKec3Rp521-bBB0bWdPnO2NSgVZg,6872
335
- cognite_neat-1.0.28.dist-info/RECORD,,
334
+ cognite_neat-1.0.30.dist-info/WHEEL,sha256=XV0cjMrO7zXhVAIyyc8aFf1VjZ33Fen4IiJk5zFlC3g,80
335
+ cognite_neat-1.0.30.dist-info/METADATA,sha256=ixyEZ8MjefyUNCq_1yz_hoqgVeARW7_jFhxCrF0OfW0,6872
336
+ cognite_neat-1.0.30.dist-info/RECORD,,