cognite-neat 0.123.43__py3-none-any.whl → 0.125.0__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.
- cognite/neat/_data_model/importers/__init__.py +2 -1
- cognite/neat/_data_model/importers/_table_importer/__init__.py +0 -0
- cognite/neat/_data_model/importers/_table_importer/data_classes.py +141 -0
- cognite/neat/_data_model/importers/_table_importer/importer.py +76 -0
- cognite/neat/_data_model/importers/_table_importer/source.py +89 -0
- cognite/neat/_data_model/models/dms/__init__.py +12 -1
- cognite/neat/_data_model/models/dms/_base.py +1 -1
- cognite/neat/_data_model/models/dms/_constraints.py +5 -2
- cognite/neat/_data_model/models/dms/_data_types.py +26 -10
- cognite/neat/_data_model/models/dms/_indexes.py +6 -3
- cognite/neat/_data_model/models/dms/_types.py +17 -0
- cognite/neat/_data_model/models/dms/_view_property.py +14 -25
- cognite/neat/_data_model/models/entities/__init__.py +2 -1
- cognite/neat/_data_model/models/entities/_parser.py +32 -0
- cognite/neat/_exceptions.py +17 -0
- cognite/neat/_session/__init__.py +0 -0
- cognite/neat/_session/_session.py +33 -0
- cognite/neat/_session/_state_machine/__init__.py +23 -0
- cognite/neat/_session/_state_machine/_base.py +27 -0
- cognite/neat/_session/_state_machine/_states.py +150 -0
- cognite/neat/_utils/text.py +22 -0
- cognite/neat/_utils/useful_types.py +4 -0
- cognite/neat/_utils/validation.py +63 -30
- cognite/neat/_version.py +1 -1
- cognite/neat/v0/core/_data_model/_constants.py +1 -0
- cognite/neat/v0/core/_data_model/exporters/_data_model2excel.py +3 -3
- cognite/neat/v0/core/_data_model/importers/_dms2data_model.py +4 -3
- cognite/neat/v0/core/_data_model/importers/_spreadsheet2data_model.py +85 -5
- cognite/neat/v0/core/_data_model/models/entities/__init__.py +2 -0
- cognite/neat/v0/core/_data_model/models/entities/_single_value.py +14 -0
- cognite/neat/v0/core/_data_model/models/entities/_types.py +10 -0
- cognite/neat/v0/core/_data_model/models/physical/_exporter.py +3 -11
- cognite/neat/v0/core/_data_model/models/physical/_unverified.py +61 -12
- cognite/neat/v0/core/_data_model/models/physical/_validation.py +8 -4
- cognite/neat/v0/core/_data_model/models/physical/_verified.py +86 -15
- cognite/neat/v0/core/_data_model/transformers/_converters.py +11 -4
- cognite/neat/v0/core/_store/_instance.py +33 -0
- cognite/neat/v0/core/_utils/spreadsheet.py +17 -3
- cognite/neat/v0/session/_base.py +2 -0
- cognite/neat/v0/session/_diff.py +51 -0
- {cognite_neat-0.123.43.dist-info → cognite_neat-0.125.0.dist-info}/METADATA +1 -1
- {cognite_neat-0.123.43.dist-info → cognite_neat-0.125.0.dist-info}/RECORD +44 -32
- {cognite_neat-0.123.43.dist-info → cognite_neat-0.125.0.dist-info}/WHEEL +0 -0
- {cognite_neat-0.123.43.dist-info → cognite_neat-0.125.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from ._issues import ModelSyntaxError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NeatException(Exception):
|
|
5
|
+
"""Base class for all exceptions raised by Neat."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DataModelImportError(NeatException):
|
|
11
|
+
"""Raised when there is an error importing a model."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, errors: list[ModelSyntaxError]) -> None:
|
|
14
|
+
self.errors = errors
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
return f"Model import failed with {len(self.errors)} errors: " + "; ".join(map(str, self.errors))
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from ._state_machine import EmptyState, ForbiddenState, State
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NeatSession:
|
|
5
|
+
"""A session is an interface for neat operations. It works as
|
|
6
|
+
a manager for handling user interactions and orchestrating
|
|
7
|
+
the state machine for data model and instance operations.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self.state: State = EmptyState()
|
|
12
|
+
|
|
13
|
+
def _execute_event(self, event: str) -> bool:
|
|
14
|
+
"""Place holder function for executing events and transitioning states.
|
|
15
|
+
It will be modified to include actual logic as we progress with v1 of neat.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
print(f"\n--- Executing event: '{event}' from {self.state} ---")
|
|
19
|
+
|
|
20
|
+
old_state = self.state
|
|
21
|
+
new_state = self.state.on_event(event)
|
|
22
|
+
|
|
23
|
+
# Handle ForbiddenState
|
|
24
|
+
if isinstance(new_state, ForbiddenState):
|
|
25
|
+
print(f"❌ Event '{event}' is FORBIDDEN from {old_state}")
|
|
26
|
+
# Return to previous state (as per your table logic)
|
|
27
|
+
self.state = new_state.on_event("undo")
|
|
28
|
+
print(f"↩️ Returned to: {self.state}")
|
|
29
|
+
return False
|
|
30
|
+
else:
|
|
31
|
+
self.state = new_state
|
|
32
|
+
print(f"✅ Transition successful: {old_state} → {self.state}")
|
|
33
|
+
return True
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from ._base import State
|
|
2
|
+
from ._states import (
|
|
3
|
+
ConceptualPhysicalState,
|
|
4
|
+
ConceptualState,
|
|
5
|
+
EmptyState,
|
|
6
|
+
ForbiddenState,
|
|
7
|
+
InstancesConceptualPhysicalState,
|
|
8
|
+
InstancesConceptualState,
|
|
9
|
+
InstancesState,
|
|
10
|
+
PhysicalState,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ConceptualPhysicalState",
|
|
15
|
+
"ConceptualState",
|
|
16
|
+
"EmptyState",
|
|
17
|
+
"ForbiddenState",
|
|
18
|
+
"InstancesConceptualPhysicalState",
|
|
19
|
+
"InstancesConceptualState",
|
|
20
|
+
"InstancesState",
|
|
21
|
+
"PhysicalState",
|
|
22
|
+
"State",
|
|
23
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class State(ABC):
|
|
5
|
+
def __init__(self) -> None:
|
|
6
|
+
# this will be reference to the actual store in the session
|
|
7
|
+
# used to store data models and instances, here only as a placeholder
|
|
8
|
+
self._store = None
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def on_event(self, event: str) -> "State":
|
|
12
|
+
"""
|
|
13
|
+
Handle events that are delegated to this State.
|
|
14
|
+
"""
|
|
15
|
+
raise NotImplementedError("on_event() must be implemented by the subclass.")
|
|
16
|
+
|
|
17
|
+
def __repr__(self) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Leverages the __str__ method to describe the State.
|
|
20
|
+
"""
|
|
21
|
+
return self.__str__()
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Returns the name of the State.
|
|
26
|
+
"""
|
|
27
|
+
return self.__class__.__name__
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from ._base import State
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EmptyState(State):
|
|
5
|
+
"""
|
|
6
|
+
The initial state with empty NEAT store.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
def on_event(self, event: str) -> State:
|
|
10
|
+
if event == "read_instances":
|
|
11
|
+
return InstancesState()
|
|
12
|
+
elif event == "read_conceptual":
|
|
13
|
+
return ConceptualState()
|
|
14
|
+
elif event == "read_physical":
|
|
15
|
+
return PhysicalState()
|
|
16
|
+
|
|
17
|
+
return ForbiddenState(self)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InstancesState(State):
|
|
21
|
+
"""
|
|
22
|
+
State with instances loaded to the store.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def on_event(self, event: str) -> State:
|
|
26
|
+
# One can keep on reading instances to stay in the same state
|
|
27
|
+
if event == "read_instances":
|
|
28
|
+
return InstancesState()
|
|
29
|
+
# We either read conceptual model or infer it from instances
|
|
30
|
+
# if read conceptual, we need to make sure that conceptual model is compatible with instances
|
|
31
|
+
elif event in ["infer_conceptual", "read_conceptual"]:
|
|
32
|
+
return InstancesConceptualState()
|
|
33
|
+
|
|
34
|
+
# transforming instances keeps us in the same state
|
|
35
|
+
elif event == "transform_instances":
|
|
36
|
+
return InstancesState()
|
|
37
|
+
# we should allow writing out instances in RDF format but not to CDF
|
|
38
|
+
elif event == "write_instances":
|
|
39
|
+
return InstancesState()
|
|
40
|
+
|
|
41
|
+
# all other operations are forbidden
|
|
42
|
+
return ForbiddenState(self)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConceptualState(State):
|
|
46
|
+
"""
|
|
47
|
+
State with conceptual model loaded.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def on_event(self, event: str) -> State:
|
|
51
|
+
# re-reading of model means transformation of
|
|
52
|
+
# the current model has been done outside of NeatSession
|
|
53
|
+
# requires checking that the new model is compatible with the existing
|
|
54
|
+
if event == "read_conceptual":
|
|
55
|
+
return ConceptualState()
|
|
56
|
+
|
|
57
|
+
# when reading: requires linking between models
|
|
58
|
+
# when converting: links are automatically created
|
|
59
|
+
elif event == "read_physical" or event == "convert_to_physical":
|
|
60
|
+
return ConceptualPhysicalState()
|
|
61
|
+
elif event == "transform_conceptual":
|
|
62
|
+
return ConceptualState()
|
|
63
|
+
elif event == "write_conceptual":
|
|
64
|
+
return ConceptualState()
|
|
65
|
+
|
|
66
|
+
return ForbiddenState(self)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PhysicalState(State):
|
|
70
|
+
"""
|
|
71
|
+
State with physical model loaded.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def on_event(self, event: str) -> State:
|
|
75
|
+
if event == "read_physical":
|
|
76
|
+
return PhysicalState()
|
|
77
|
+
elif event == "transform_physical":
|
|
78
|
+
return PhysicalState()
|
|
79
|
+
elif event == "write_physical":
|
|
80
|
+
return PhysicalState()
|
|
81
|
+
elif event == "convert_to_conceptual" or event == "read_conceptual":
|
|
82
|
+
return ConceptualPhysicalState()
|
|
83
|
+
|
|
84
|
+
return ForbiddenState(self)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class InstancesConceptualState(State):
|
|
88
|
+
"""
|
|
89
|
+
State with both instances and conceptual model loaded.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def on_event(self, event: str) -> State:
|
|
93
|
+
if event == "read_conceptual":
|
|
94
|
+
return InstancesConceptualState()
|
|
95
|
+
elif event == "transform_conceptual":
|
|
96
|
+
return InstancesConceptualState()
|
|
97
|
+
elif event in ["write_instances", "write_conceptual"]:
|
|
98
|
+
return InstancesConceptualState()
|
|
99
|
+
elif event in ["read_physical", "convert_to_physical"]:
|
|
100
|
+
return InstancesConceptualPhysicalState()
|
|
101
|
+
|
|
102
|
+
return ForbiddenState(self)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ConceptualPhysicalState(State):
|
|
106
|
+
"""
|
|
107
|
+
State with both conceptual and physical models loaded.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def on_event(self, event: str) -> State:
|
|
111
|
+
if event == "read_physical":
|
|
112
|
+
return ConceptualPhysicalState()
|
|
113
|
+
elif event == "transform_physical":
|
|
114
|
+
return ConceptualPhysicalState()
|
|
115
|
+
elif event in ["write_conceptual", "write_physical"]:
|
|
116
|
+
return ConceptualPhysicalState()
|
|
117
|
+
|
|
118
|
+
return ForbiddenState(self)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class InstancesConceptualPhysicalState(State):
|
|
122
|
+
"""
|
|
123
|
+
State with instances, conceptual, and physical models loaded.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def on_event(self, event: str) -> State:
|
|
127
|
+
if event == "read_physical":
|
|
128
|
+
return InstancesConceptualPhysicalState()
|
|
129
|
+
elif event == "transform_physical":
|
|
130
|
+
return InstancesConceptualPhysicalState()
|
|
131
|
+
elif event in ["write_instances", "write_conceptual", "write_physical"]:
|
|
132
|
+
return InstancesConceptualPhysicalState()
|
|
133
|
+
|
|
134
|
+
return ForbiddenState(self)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ForbiddenState(State):
|
|
138
|
+
"""
|
|
139
|
+
State representing forbidden transitions - returns to previous state.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(self, previous_state: State):
|
|
143
|
+
self.previous_state = previous_state
|
|
144
|
+
print(f"Forbidden action attempted. Returning to previous state: {previous_state}")
|
|
145
|
+
|
|
146
|
+
def on_event(self, event: str) -> State:
|
|
147
|
+
# only "undo" to trigger going back to previous state
|
|
148
|
+
if event.strip().lower() == "undo":
|
|
149
|
+
return self.previous_state
|
|
150
|
+
return self
|
cognite/neat/_utils/text.py
CHANGED
|
@@ -38,3 +38,25 @@ def humanize_collection(collection: Collection[Any], /, *, sort: bool = True, bi
|
|
|
38
38
|
sequence = list(strings)
|
|
39
39
|
|
|
40
40
|
return f"{', '.join(sequence[:-1])} {bind_word} {sequence[-1]}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def title_case(s: str) -> str:
|
|
44
|
+
"""Convert a string to title case, handling underscores and hyphens.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
s: The string to convert.
|
|
48
|
+
Returns:
|
|
49
|
+
The title-cased string.
|
|
50
|
+
Examples:
|
|
51
|
+
>>> title_case("hello world")
|
|
52
|
+
'Hello World'
|
|
53
|
+
>>> title_case("hello_world")
|
|
54
|
+
'Hello World'
|
|
55
|
+
>>> title_case("hello-world")
|
|
56
|
+
'Hello World'
|
|
57
|
+
>>> title_case("hello_world-and-universe")
|
|
58
|
+
'Hello World And Universe'
|
|
59
|
+
>>> title_case("HELLO WORLD")
|
|
60
|
+
'Hello World'
|
|
61
|
+
"""
|
|
62
|
+
return " ".join(word.capitalize() for word in s.replace("_", " ").replace("-", " ").split())
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
from collections.abc import Hashable
|
|
2
|
+
from datetime import date, datetime, time, timedelta
|
|
2
3
|
from typing import TypeAlias, TypeVar
|
|
3
4
|
|
|
4
5
|
JsonVal: TypeAlias = None | str | int | float | bool | dict[str, "JsonVal"] | list["JsonVal"]
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
T_ID = TypeVar("T_ID", bound=Hashable)
|
|
9
|
+
|
|
10
|
+
# These are the types that openpyxl supports in cells
|
|
11
|
+
CellValueType: TypeAlias = str | int | float | bool | datetime | date | time | timedelta | None
|
|
@@ -1,28 +1,71 @@
|
|
|
1
|
+
from collections.abc import Callable, Mapping
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
1
4
|
from pydantic import ValidationError
|
|
2
5
|
from pydantic_core import ErrorDetails
|
|
3
6
|
|
|
4
7
|
|
|
5
|
-
def
|
|
8
|
+
def as_json_path(loc: tuple[str | int, ...]) -> str:
|
|
9
|
+
"""Converts a location tuple to a JSON path.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
loc: The location tuple to convert.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
A JSON path string.
|
|
16
|
+
"""
|
|
17
|
+
if not loc:
|
|
18
|
+
return ""
|
|
19
|
+
# +1 to convert from 0-based to 1-based indexing
|
|
20
|
+
prefix = ""
|
|
21
|
+
if isinstance(loc[0], int):
|
|
22
|
+
prefix = "item"
|
|
23
|
+
|
|
24
|
+
suffix = ".".join([str(x) if isinstance(x, str) else f"[{x + 1}]" for x in loc]).replace(".[", "[")
|
|
25
|
+
return f"{prefix}{suffix}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def humanize_validation_error(
|
|
29
|
+
error: ValidationError,
|
|
30
|
+
parent_loc: tuple[int | str, ...] = tuple(),
|
|
31
|
+
humanize_location: Callable[[tuple[int | str, ...]], str] = as_json_path,
|
|
32
|
+
field_name: Literal["field", "column", "value"] = "field",
|
|
33
|
+
field_renaming: Mapping[str, str] | None = None,
|
|
34
|
+
missing_required_descriptor: Literal["empty", "missing"] = "missing",
|
|
35
|
+
) -> list[str]:
|
|
6
36
|
"""Converts a ValidationError to a human-readable format.
|
|
7
37
|
|
|
8
38
|
This overwrites the default error messages from Pydantic to be better suited for Toolkit users.
|
|
9
39
|
|
|
10
40
|
Args:
|
|
11
41
|
error: The ValidationError to convert.
|
|
12
|
-
|
|
42
|
+
parent_loc: Optional location tuple to prepend to each error location.
|
|
43
|
+
This is useful when the error is for a nested model and you want to include the location
|
|
44
|
+
of the parent model.
|
|
45
|
+
humanize_location: A function that converts a location tuple to a human-readable string.
|
|
46
|
+
The default is `as_json_path`, which converts the location to a JSON path.
|
|
47
|
+
This can for example be replaced when the location comes from an Excel table.
|
|
48
|
+
field_name: The name use for "field" in error messages. Default is "field". This can be changed to
|
|
49
|
+
"column" or "value" to better fit the context.
|
|
50
|
+
field_renaming: Optional mapping of field names to source names.
|
|
51
|
+
This is useful when the field names in the model are different from the names in the source.
|
|
52
|
+
For example, if the model field is "asset_id" but the source column is "Asset ID",
|
|
53
|
+
you can provide a mapping {"asset_id": "Asset ID"} to have the error messages use the source names.
|
|
54
|
+
missing_required_descriptor: How to describe missing required fields. Default is "missing".
|
|
55
|
+
Other option is "empty" which can be more suitable for table data.
|
|
13
56
|
Returns:
|
|
14
57
|
A list of human-readable error messages.
|
|
15
58
|
"""
|
|
16
59
|
errors: list[str] = []
|
|
17
60
|
item: ErrorDetails
|
|
18
|
-
|
|
61
|
+
field_renaming = field_renaming or {}
|
|
19
62
|
for item in error.errors(include_input=True, include_url=False):
|
|
20
|
-
loc = item["loc"]
|
|
63
|
+
loc = (*parent_loc, *item["loc"])
|
|
21
64
|
error_type = item["type"]
|
|
22
65
|
if error_type == "missing":
|
|
23
|
-
msg = f"Missing required
|
|
66
|
+
msg = f"Missing required {field_name}: {loc[-1]!r}"
|
|
24
67
|
elif error_type == "extra_forbidden":
|
|
25
|
-
msg = f"Unused
|
|
68
|
+
msg = f"Unused {field_name}: {loc[-1]!r}"
|
|
26
69
|
elif error_type == "value_error":
|
|
27
70
|
msg = str(item["ctx"]["error"])
|
|
28
71
|
elif error_type == "literal_error":
|
|
@@ -50,32 +93,22 @@ def humanize_validation_error(error: ValidationError) -> list[str]:
|
|
|
50
93
|
# This is hard to read, so we simplify it to just the field name.
|
|
51
94
|
loc = tuple(["dict" if isinstance(x, str) and "json-or-python" in x else x for x in loc])
|
|
52
95
|
|
|
96
|
+
error_suffix = f"{msg[:1].casefold()}{msg[1:]}"
|
|
53
97
|
if len(loc) > 1 and error_type in {"extra_forbidden", "missing"}:
|
|
54
|
-
|
|
55
|
-
|
|
98
|
+
if missing_required_descriptor == "empty" and error_type == "missing":
|
|
99
|
+
# This is a table so we modify the error message.
|
|
100
|
+
msg = (
|
|
101
|
+
f"In {humanize_location(loc[:-1])} the {field_name} {field_renaming.get(str(loc[-1]), loc[-1])!r} "
|
|
102
|
+
"cannot be empty."
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
# We skip the last element as this is in the message already
|
|
106
|
+
msg = f"In {humanize_location(loc[:-1])} {error_suffix.replace('field', field_name)}"
|
|
56
107
|
elif len(loc) > 1:
|
|
57
|
-
msg = f"In {
|
|
108
|
+
msg = f"In {humanize_location(loc)} {error_suffix}"
|
|
58
109
|
elif len(loc) == 1 and isinstance(loc[0], str) and error_type not in {"extra_forbidden", "missing"}:
|
|
59
|
-
msg = f"In
|
|
110
|
+
msg = f"In {field_name} {loc[0]!r}, {error_suffix}"
|
|
111
|
+
if not msg.endswith("."):
|
|
112
|
+
msg += "."
|
|
60
113
|
errors.append(msg)
|
|
61
114
|
return errors
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def as_json_path(loc: tuple[str | int, ...]) -> str:
|
|
65
|
-
"""Converts a location tuple to a JSON path.
|
|
66
|
-
|
|
67
|
-
Args:
|
|
68
|
-
loc: The location tuple to convert.
|
|
69
|
-
|
|
70
|
-
Returns:
|
|
71
|
-
A JSON path string.
|
|
72
|
-
"""
|
|
73
|
-
if not loc:
|
|
74
|
-
return ""
|
|
75
|
-
# +1 to convert from 0-based to 1-based indexing
|
|
76
|
-
prefix = ""
|
|
77
|
-
if isinstance(loc[0], int):
|
|
78
|
-
prefix = "item "
|
|
79
|
-
|
|
80
|
-
suffix = ".".join([str(x) if isinstance(x, str) else f"[{x + 1}]" for x in loc]).replace(".[", "[")
|
|
81
|
-
return f"{prefix}{suffix}"
|
cognite/neat/_version.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.125.0"
|
|
2
2
|
__engine__ = "^2.0.4"
|
|
@@ -43,6 +43,7 @@ class EntityTypes(StrEnum):
|
|
|
43
43
|
prefix = "prefix"
|
|
44
44
|
space = "space"
|
|
45
45
|
container_index = "container_index"
|
|
46
|
+
container_constraint = "container_constraint"
|
|
46
47
|
concept_restriction = "conceptRestriction"
|
|
47
48
|
value_constraint = "valueConstraint"
|
|
48
49
|
cardinality_constraint = "cardinalityConstraint"
|
|
@@ -32,7 +32,7 @@ from cognite.neat.v0.core._data_model.models.data_types import (
|
|
|
32
32
|
)
|
|
33
33
|
from cognite.neat.v0.core._data_model.models.physical._verified import PhysicalDataModel
|
|
34
34
|
from cognite.neat.v0.core._utils.spreadsheet import (
|
|
35
|
-
|
|
35
|
+
find_column_and_row_with_value,
|
|
36
36
|
generate_data_validation,
|
|
37
37
|
)
|
|
38
38
|
|
|
@@ -217,7 +217,7 @@ class ExcelExporter(BaseExporter[VerifiedDataModel, Workbook]):
|
|
|
217
217
|
continue
|
|
218
218
|
ws = workbook[sheet]
|
|
219
219
|
for col in get_internal_properties():
|
|
220
|
-
column_letter =
|
|
220
|
+
column_letter = find_column_and_row_with_value(ws, col)[0]
|
|
221
221
|
if column_letter:
|
|
222
222
|
ws.column_dimensions[column_letter].hidden = True
|
|
223
223
|
|
|
@@ -451,7 +451,7 @@ class ExcelExporter(BaseExporter[VerifiedDataModel, Workbook]):
|
|
|
451
451
|
workbook[sheet_name].add_data_validation(data_validators[data_validator_name])
|
|
452
452
|
|
|
453
453
|
# APPLY VALIDATOR TO SPECIFIC COLUMN
|
|
454
|
-
if column_letter :=
|
|
454
|
+
if column_letter := find_column_and_row_with_value(workbook[sheet_name], column_name)[0]:
|
|
455
455
|
data_validators[data_validator_name].add(f"{column_letter}{3}:{column_letter}{3 + total_rows}")
|
|
456
456
|
|
|
457
457
|
def _create_sheet_with_header(
|
|
@@ -54,6 +54,7 @@ from cognite.neat.v0.core._data_model.models.entities import (
|
|
|
54
54
|
ReverseConnectionEntity,
|
|
55
55
|
ViewEntity,
|
|
56
56
|
)
|
|
57
|
+
from cognite.neat.v0.core._data_model.models.entities._single_value import ContainerConstraintEntity
|
|
57
58
|
from cognite.neat.v0.core._data_model.models.physical import (
|
|
58
59
|
UnverifiedPhysicalContainer,
|
|
59
60
|
UnverifiedPhysicalEnum,
|
|
@@ -571,17 +572,17 @@ class DMSImporter(BaseImporter[UnverifiedPhysicalDataModel]):
|
|
|
571
572
|
index.append(ContainerIndexEntity(prefix="inverted", suffix=index_name, order=order))
|
|
572
573
|
return index or None
|
|
573
574
|
|
|
574
|
-
def _get_constraint(self, prop: ViewPropertyApply, prop_id: str) -> list[
|
|
575
|
+
def _get_constraint(self, prop: ViewPropertyApply, prop_id: str) -> list[ContainerConstraintEntity] | None:
|
|
575
576
|
if not isinstance(prop, dm.MappedPropertyApply):
|
|
576
577
|
return None
|
|
577
578
|
container = self._all_containers_by_id[prop.container]
|
|
578
|
-
unique_constraints: list[
|
|
579
|
+
unique_constraints: list[ContainerConstraintEntity] = []
|
|
579
580
|
for constraint_name, constraint_obj in (container.constraints or {}).items():
|
|
580
581
|
if isinstance(constraint_obj, dm.RequiresConstraint):
|
|
581
582
|
# This is handled in the .from_container method of DMSContainer
|
|
582
583
|
continue
|
|
583
584
|
elif isinstance(constraint_obj, dm.UniquenessConstraint) and prop_id in constraint_obj.properties:
|
|
584
|
-
unique_constraints.append(constraint_name)
|
|
585
|
+
unique_constraints.append(ContainerConstraintEntity(prefix="uniqueness", suffix=constraint_name))
|
|
585
586
|
elif isinstance(constraint_obj, dm.UniquenessConstraint):
|
|
586
587
|
# This does not apply to this property
|
|
587
588
|
continue
|
|
@@ -10,8 +10,10 @@ import pandas as pd
|
|
|
10
10
|
from openpyxl import load_workbook
|
|
11
11
|
from openpyxl.worksheet.worksheet import Worksheet
|
|
12
12
|
from pandas import ExcelFile
|
|
13
|
+
from pydantic import ValidationError
|
|
13
14
|
from rdflib import Namespace, URIRef
|
|
14
15
|
|
|
16
|
+
from cognite.neat.v0.core._data_model._constants import SPLIT_ON_COMMA_PATTERN
|
|
15
17
|
from cognite.neat.v0.core._data_model._shared import (
|
|
16
18
|
ImportedDataModel,
|
|
17
19
|
T_UnverifiedDataModel,
|
|
@@ -23,6 +25,7 @@ from cognite.neat.v0.core._data_model.models import (
|
|
|
23
25
|
SchemaCompleteness,
|
|
24
26
|
)
|
|
25
27
|
from cognite.neat.v0.core._data_model.models._import_contexts import SpreadsheetContext
|
|
28
|
+
from cognite.neat.v0.core._data_model.models.entities._single_value import ContainerConstraintEntity, ContainerEntity
|
|
26
29
|
from cognite.neat.v0.core._issues import IssueList, MultiValueError
|
|
27
30
|
from cognite.neat.v0.core._issues.errors import (
|
|
28
31
|
FileMissingRequiredFieldError,
|
|
@@ -30,7 +33,11 @@ from cognite.neat.v0.core._issues.errors import (
|
|
|
30
33
|
FileReadError,
|
|
31
34
|
)
|
|
32
35
|
from cognite.neat.v0.core._issues.warnings import FileMissingRequiredFieldWarning
|
|
33
|
-
from cognite.neat.v0.core._utils.spreadsheet import
|
|
36
|
+
from cognite.neat.v0.core._utils.spreadsheet import (
|
|
37
|
+
SpreadsheetRead,
|
|
38
|
+
find_column_and_row_with_value,
|
|
39
|
+
read_individual_sheet,
|
|
40
|
+
)
|
|
34
41
|
from cognite.neat.v0.core._utils.text import humanize_collection
|
|
35
42
|
|
|
36
43
|
from ._base import BaseImporter
|
|
@@ -306,7 +313,7 @@ class ExcelImporter(BaseImporter[T_UnverifiedDataModel]):
|
|
|
306
313
|
|
|
307
314
|
"""
|
|
308
315
|
|
|
309
|
-
workbook = load_workbook(filepath)
|
|
316
|
+
workbook = load_workbook(filepath, data_only=True)
|
|
310
317
|
|
|
311
318
|
if "Classes" in workbook.sheetnames:
|
|
312
319
|
print(
|
|
@@ -323,13 +330,17 @@ class ExcelImporter(BaseImporter[T_UnverifiedDataModel]):
|
|
|
323
330
|
if "Properties" in workbook.sheetnames:
|
|
324
331
|
_replace_class_with_concept_cell(workbook["Properties"])
|
|
325
332
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
333
|
+
elif "Containers" in workbook.sheetnames:
|
|
334
|
+
_replace_legacy_constraint_form(workbook["Containers"])
|
|
335
|
+
_replace_legacy_constraint_form(workbook["Properties"])
|
|
329
336
|
|
|
330
337
|
else:
|
|
331
338
|
return filepath
|
|
332
339
|
|
|
340
|
+
with tempfile.NamedTemporaryFile(prefix="temp_neat_file", suffix=".xlsx", delete=False) as temp_file:
|
|
341
|
+
workbook.save(temp_file.name)
|
|
342
|
+
return Path(temp_file.name)
|
|
343
|
+
|
|
333
344
|
|
|
334
345
|
def _replace_class_with_concept_cell(sheet: Worksheet) -> None:
|
|
335
346
|
"""
|
|
@@ -342,3 +353,72 @@ def _replace_class_with_concept_cell(sheet: Worksheet) -> None:
|
|
|
342
353
|
for cell in row:
|
|
343
354
|
if cell.value == "Class":
|
|
344
355
|
cell.value = "Concept"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _replace_legacy_constraint_form(sheet: Worksheet) -> None:
|
|
359
|
+
"""
|
|
360
|
+
Replaces the legacy form of container constraints with the new form in the given sheet.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
sheet (Worksheet): The sheet in which to replace the old form of container constraints.
|
|
364
|
+
"""
|
|
365
|
+
column, row = find_column_and_row_with_value(sheet, "Constraint", False)
|
|
366
|
+
|
|
367
|
+
if not column or not row:
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
# Iterate over values in the constraint column and replace old form with new form
|
|
371
|
+
replaced: bool = False
|
|
372
|
+
for cell_row in sheet.iter_rows(min_row=row + 1, min_col=column, max_col=column):
|
|
373
|
+
cell = cell_row[0]
|
|
374
|
+
if cell.value is not None: # Skip empty cells
|
|
375
|
+
# Container sheet update
|
|
376
|
+
if sheet.title.lower() == "containers":
|
|
377
|
+
constraints = []
|
|
378
|
+
for constraint in SPLIT_ON_COMMA_PATTERN.split(str(cell.value)):
|
|
379
|
+
# latest format, do nothing
|
|
380
|
+
if "container" in constraint.lower():
|
|
381
|
+
constraints.append(constraint)
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
container = ContainerEntity.load(constraint, space="default")
|
|
386
|
+
container_str = container.external_id if container.space == "default" else str(container)
|
|
387
|
+
constraints.append(
|
|
388
|
+
f"requires:{container.space}_{container.external_id}(container={container_str})"
|
|
389
|
+
)
|
|
390
|
+
replaced = True
|
|
391
|
+
except ValidationError:
|
|
392
|
+
constraints.append(constraint)
|
|
393
|
+
|
|
394
|
+
cell.value = ",".join(constraints)
|
|
395
|
+
|
|
396
|
+
# Properties sheet update
|
|
397
|
+
elif sheet.title.lower() == "properties":
|
|
398
|
+
constraints = []
|
|
399
|
+
for constraint in SPLIT_ON_COMMA_PATTERN.split(str(cell.value)):
|
|
400
|
+
try:
|
|
401
|
+
constraint_entity = ContainerConstraintEntity.load(constraint)
|
|
402
|
+
|
|
403
|
+
if constraint_entity.prefix in ["uniqueness", "requires"]:
|
|
404
|
+
constraints.append(constraint)
|
|
405
|
+
|
|
406
|
+
# Replace old format with new format
|
|
407
|
+
else:
|
|
408
|
+
constraints.append(f"uniqueness:{constraint}")
|
|
409
|
+
replaced = True
|
|
410
|
+
|
|
411
|
+
# If the constraint is not valid, we keep it as is
|
|
412
|
+
# to be caught by validation later
|
|
413
|
+
except ValidationError:
|
|
414
|
+
constraints.append(constraint)
|
|
415
|
+
|
|
416
|
+
cell.value = ",".join(constraints)
|
|
417
|
+
|
|
418
|
+
if replaced:
|
|
419
|
+
print(
|
|
420
|
+
(
|
|
421
|
+
"You are using a legacy container constraints format "
|
|
422
|
+
f"in the {sheet.title} sheet. Please update to the latest format."
|
|
423
|
+
),
|
|
424
|
+
)
|
|
@@ -7,6 +7,7 @@ from ._single_value import (
|
|
|
7
7
|
AssetFields,
|
|
8
8
|
ConceptEntity,
|
|
9
9
|
ConceptualEntity,
|
|
10
|
+
ContainerConstraintEntity,
|
|
10
11
|
ContainerEntity,
|
|
11
12
|
ContainerIndexEntity,
|
|
12
13
|
DataModelEntity,
|
|
@@ -35,6 +36,7 @@ __all__ = [
|
|
|
35
36
|
"ConceptPropertyCardinalityConstraint",
|
|
36
37
|
"ConceptPropertyValueConstraint",
|
|
37
38
|
"ConceptualEntity",
|
|
39
|
+
"ContainerConstraintEntity",
|
|
38
40
|
"ContainerEntity",
|
|
39
41
|
"ContainerEntityList",
|
|
40
42
|
"ContainerIndexEntity",
|
|
@@ -660,3 +660,17 @@ class ContainerIndexEntity(PhysicalEntity[None]):
|
|
|
660
660
|
@classmethod
|
|
661
661
|
def from_id(cls, id: None) -> Self:
|
|
662
662
|
return cls(suffix="dummy")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
class ContainerConstraintEntity(PhysicalEntity[None]):
|
|
666
|
+
type_: ClassVar[EntityTypes] = EntityTypes.container_constraint
|
|
667
|
+
prefix: _UndefinedType | Literal["uniqueness", "requires"] = Undefined # type: ignore[assignment]
|
|
668
|
+
suffix: str
|
|
669
|
+
container: ContainerEntity | None = None
|
|
670
|
+
|
|
671
|
+
def as_id(self) -> None:
|
|
672
|
+
return None
|
|
673
|
+
|
|
674
|
+
@classmethod
|
|
675
|
+
def from_id(cls, id: None) -> Self:
|
|
676
|
+
return cls(suffix="dummy")
|