cognite-neat 0.125.1__py3-none-any.whl → 0.126.1__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/_client/__init__.py +4 -0
- cognite/neat/_client/api.py +8 -0
- cognite/neat/_client/client.py +19 -0
- cognite/neat/_client/config.py +40 -0
- cognite/neat/_client/containers_api.py +73 -0
- cognite/neat/_client/data_classes.py +10 -0
- cognite/neat/_client/data_model_api.py +63 -0
- cognite/neat/_client/spaces_api.py +67 -0
- cognite/neat/_client/views_api.py +82 -0
- cognite/neat/_data_model/_analysis.py +127 -0
- cognite/neat/_data_model/_constants.py +59 -0
- cognite/neat/_data_model/_shared.py +46 -0
- cognite/neat/_data_model/deployer/__init__.py +0 -0
- cognite/neat/_data_model/deployer/_differ.py +113 -0
- cognite/neat/_data_model/deployer/_differ_container.py +354 -0
- cognite/neat/_data_model/deployer/_differ_data_model.py +29 -0
- cognite/neat/_data_model/deployer/_differ_space.py +9 -0
- cognite/neat/_data_model/deployer/_differ_view.py +194 -0
- cognite/neat/_data_model/deployer/data_classes.py +176 -0
- cognite/neat/_data_model/exporters/__init__.py +4 -0
- cognite/neat/_data_model/exporters/_base.py +22 -0
- cognite/neat/_data_model/exporters/_table_exporter/__init__.py +0 -0
- cognite/neat/_data_model/exporters/_table_exporter/exporter.py +106 -0
- cognite/neat/_data_model/exporters/_table_exporter/workbook.py +414 -0
- cognite/neat/_data_model/exporters/_table_exporter/writer.py +391 -0
- cognite/neat/_data_model/importers/__init__.py +2 -1
- cognite/neat/_data_model/importers/_api_importer.py +88 -0
- cognite/neat/_data_model/importers/_table_importer/data_classes.py +48 -8
- cognite/neat/_data_model/importers/_table_importer/importer.py +102 -6
- cognite/neat/_data_model/importers/_table_importer/reader.py +860 -0
- cognite/neat/_data_model/models/dms/__init__.py +19 -1
- cognite/neat/_data_model/models/dms/_base.py +12 -8
- cognite/neat/_data_model/models/dms/_constants.py +1 -1
- cognite/neat/_data_model/models/dms/_constraints.py +2 -1
- cognite/neat/_data_model/models/dms/_container.py +5 -5
- cognite/neat/_data_model/models/dms/_data_model.py +3 -3
- cognite/neat/_data_model/models/dms/_data_types.py +8 -1
- cognite/neat/_data_model/models/dms/_http.py +18 -0
- cognite/neat/_data_model/models/dms/_indexes.py +2 -1
- cognite/neat/_data_model/models/dms/_references.py +17 -4
- cognite/neat/_data_model/models/dms/_space.py +11 -7
- cognite/neat/_data_model/models/dms/_view_property.py +7 -4
- cognite/neat/_data_model/models/dms/_views.py +16 -6
- cognite/neat/_data_model/validation/__init__.py +0 -0
- cognite/neat/_data_model/validation/_base.py +16 -0
- cognite/neat/_data_model/validation/dms/__init__.py +9 -0
- cognite/neat/_data_model/validation/dms/_orchestrator.py +68 -0
- cognite/neat/_data_model/validation/dms/_validators.py +139 -0
- cognite/neat/_exceptions.py +15 -3
- cognite/neat/_issues.py +39 -6
- cognite/neat/_session/__init__.py +3 -0
- cognite/neat/_session/_physical.py +88 -0
- cognite/neat/_session/_session.py +34 -25
- cognite/neat/_session/_wrappers.py +61 -0
- cognite/neat/_state_machine/__init__.py +10 -0
- cognite/neat/{_session/_state_machine → _state_machine}/_base.py +11 -1
- cognite/neat/_state_machine/_states.py +53 -0
- cognite/neat/_store/__init__.py +3 -0
- cognite/neat/_store/_provenance.py +55 -0
- cognite/neat/_store/_store.py +124 -0
- cognite/neat/_utils/_reader.py +194 -0
- cognite/neat/_utils/http_client/__init__.py +14 -20
- cognite/neat/_utils/http_client/_client.py +22 -61
- cognite/neat/_utils/http_client/_data_classes.py +167 -268
- cognite/neat/_utils/text.py +6 -0
- cognite/neat/_utils/useful_types.py +26 -2
- cognite/neat/_version.py +1 -1
- cognite/neat/v0/core/_data_model/importers/_rdf/_shared.py +2 -2
- cognite/neat/v0/core/_data_model/importers/_spreadsheet2data_model.py +2 -2
- cognite/neat/v0/core/_data_model/models/entities/_single_value.py +1 -1
- cognite/neat/v0/core/_data_model/models/physical/_unverified.py +1 -1
- cognite/neat/v0/core/_data_model/models/physical/_validation.py +2 -2
- cognite/neat/v0/core/_data_model/models/physical/_verified.py +3 -3
- cognite/neat/v0/core/_data_model/transformers/_converters.py +1 -1
- {cognite_neat-0.125.1.dist-info → cognite_neat-0.126.1.dist-info}/METADATA +1 -1
- {cognite_neat-0.125.1.dist-info → cognite_neat-0.126.1.dist-info}/RECORD +78 -40
- cognite/neat/_session/_state_machine/__init__.py +0 -23
- cognite/neat/_session/_state_machine/_states.py +0 -150
- {cognite_neat-0.125.1.dist-info → cognite_neat-0.126.1.dist-info}/WHEEL +0 -0
- {cognite_neat-0.125.1.dist-info → cognite_neat-0.126.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from collections import UserList
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from cognite.neat._data_model._shared import OnSuccess, OnSuccessIssuesChecker, OnSuccessResultProducer
|
|
7
|
+
from cognite.neat._data_model.exporters import DMSTableExporter
|
|
8
|
+
from cognite.neat._data_model.importers import DMSImporter, DMSTableImporter
|
|
9
|
+
from cognite.neat._data_model.models.dms import RequestSchema as PhysicalDataModel
|
|
10
|
+
from cognite.neat._exceptions import DataModelImportException
|
|
11
|
+
from cognite.neat._issues import IssueList
|
|
12
|
+
from cognite.neat._state_machine._states import EmptyState, State
|
|
13
|
+
|
|
14
|
+
from ._provenance import Change, Provenance
|
|
15
|
+
|
|
16
|
+
Agents = DMSTableExporter | DMSTableImporter | DMSImporter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NeatStore:
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.physical_data_model = DataModelList()
|
|
22
|
+
self.provenance = Provenance()
|
|
23
|
+
self.state: State = EmptyState()
|
|
24
|
+
|
|
25
|
+
def read_physical(self, reader: DMSImporter, on_success: OnSuccess | None = None) -> None:
|
|
26
|
+
"""Read object from the store"""
|
|
27
|
+
self._can_agent_do_activity(reader)
|
|
28
|
+
|
|
29
|
+
change, data_model = self._do_activity(reader.to_data_model, on_success)
|
|
30
|
+
|
|
31
|
+
if data_model:
|
|
32
|
+
change.target_entity = self.physical_data_model.generate_reference(cast(PhysicalDataModel, data_model))
|
|
33
|
+
self.physical_data_model.append(data_model)
|
|
34
|
+
self.state = self.state.transition(reader)
|
|
35
|
+
change.target_state = self.state
|
|
36
|
+
# in case of read of data model result will be same as target entity
|
|
37
|
+
change.result = change.target_entity
|
|
38
|
+
|
|
39
|
+
self.provenance.append(change)
|
|
40
|
+
|
|
41
|
+
def write_physical(self, writer: DMSTableExporter, on_success: OnSuccess | None = None, **kwargs: Any) -> None:
|
|
42
|
+
"""Write object into the store"""
|
|
43
|
+
self._can_agent_do_activity(writer)
|
|
44
|
+
|
|
45
|
+
change, _ = self._do_activity(writer.export, on_success, data_model=self.physical_data_model[-1], **kwargs)
|
|
46
|
+
|
|
47
|
+
if not change.issues:
|
|
48
|
+
change.target_entity = "ExternalEntity"
|
|
49
|
+
self.state = self.state.transition(writer)
|
|
50
|
+
change.target_state = self.state
|
|
51
|
+
|
|
52
|
+
self.provenance.append(change)
|
|
53
|
+
|
|
54
|
+
def _can_agent_do_activity(self, agent: Agents) -> None:
|
|
55
|
+
"""Validate if activity can be performed in the current state and considering provenance"""
|
|
56
|
+
if not self.state.can_transition(agent):
|
|
57
|
+
raise RuntimeError(f"Cannot run {type(agent).__name__} in state {self.state}")
|
|
58
|
+
|
|
59
|
+
# need implementation of checking if required predecessor activities have been done
|
|
60
|
+
# this will be done by running self.provenance.can_agent_do_activity(agent)
|
|
61
|
+
|
|
62
|
+
def _do_activity(
|
|
63
|
+
self, activity: Callable, on_success: OnSuccess | None = None, **kwargs: Any
|
|
64
|
+
) -> tuple[Change, PhysicalDataModel | None]:
|
|
65
|
+
"""Execute activity and capture timing, results, and issues"""
|
|
66
|
+
start = datetime.now(timezone.utc)
|
|
67
|
+
result: PhysicalDataModel | None = None
|
|
68
|
+
issues = IssueList()
|
|
69
|
+
errors = IssueList()
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = activity(**kwargs)
|
|
73
|
+
|
|
74
|
+
if result and on_success:
|
|
75
|
+
if isinstance(on_success, OnSuccessIssuesChecker):
|
|
76
|
+
on_success.run(result)
|
|
77
|
+
issues.extend(on_success.issues)
|
|
78
|
+
elif isinstance(on_success, OnSuccessResultProducer):
|
|
79
|
+
raise NotImplementedError("OnSuccessResultProducer is not implemented yet.")
|
|
80
|
+
else:
|
|
81
|
+
raise RuntimeError(f"Unknown OnSuccess type {type(on_success).__name__}")
|
|
82
|
+
|
|
83
|
+
# we catch import exceptions to capture issues and errors in provenance
|
|
84
|
+
except DataModelImportException as e:
|
|
85
|
+
errors.extend(e.errors)
|
|
86
|
+
|
|
87
|
+
# these are all other errors, such as missing file, wrong format, etc.
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise e
|
|
90
|
+
|
|
91
|
+
end = datetime.now(timezone.utc)
|
|
92
|
+
|
|
93
|
+
return Change(
|
|
94
|
+
start=start,
|
|
95
|
+
end=end,
|
|
96
|
+
source_state=self.state,
|
|
97
|
+
agent=type(activity.__self__).__name__ if hasattr(activity, "__self__") else "UnknownAgent",
|
|
98
|
+
issues=issues,
|
|
99
|
+
errors=errors,
|
|
100
|
+
activity=Change.standardize_activity_name(activity.__name__, start, end),
|
|
101
|
+
), result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DataModelList(UserList[PhysicalDataModel]):
|
|
105
|
+
def iteration(self, data_model: PhysicalDataModel) -> int:
|
|
106
|
+
"""Get iteration number for data model"""
|
|
107
|
+
for i, existing in enumerate(self):
|
|
108
|
+
if existing.data_model == data_model.data_model:
|
|
109
|
+
return i + 2
|
|
110
|
+
return 1
|
|
111
|
+
|
|
112
|
+
def generate_reference(self, data_model: PhysicalDataModel) -> str:
|
|
113
|
+
"""Generate reference string for data model based on iteration"""
|
|
114
|
+
space = data_model.data_model.space
|
|
115
|
+
external_id = data_model.data_model.external_id
|
|
116
|
+
version = data_model.data_model.version
|
|
117
|
+
iteration = self.iteration(data_model)
|
|
118
|
+
|
|
119
|
+
return f"physical/{space}/{external_id}/{version}/{iteration}"
|
|
120
|
+
|
|
121
|
+
def get_by_reference(self, reference: str) -> PhysicalDataModel | None:
|
|
122
|
+
"""Get data model by reference string"""
|
|
123
|
+
|
|
124
|
+
raise NotImplementedError("Not implemented yet")
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from io import StringIO
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import IO, Any, TextIO
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NeatReader(ABC):
|
|
13
|
+
@classmethod
|
|
14
|
+
def create(cls, io: Any) -> "NeatReader":
|
|
15
|
+
if isinstance(io, str):
|
|
16
|
+
url = urlparse(io)
|
|
17
|
+
if url.scheme == "https" and url.netloc.endswith("github.com"):
|
|
18
|
+
return GitHubReader(io)
|
|
19
|
+
elif url.scheme == "https":
|
|
20
|
+
return HttpFileReader(io, url.path)
|
|
21
|
+
|
|
22
|
+
if isinstance(io, str):
|
|
23
|
+
return PathReader(Path(io))
|
|
24
|
+
if isinstance(io, Path):
|
|
25
|
+
return PathReader(io)
|
|
26
|
+
raise ValueError(f"Unsupported type: {type(io)}")
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def name(self) -> str:
|
|
30
|
+
return str(self)
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def read_text(self) -> str:
|
|
34
|
+
"""Read the buffer as a string"""
|
|
35
|
+
raise NotImplementedError()
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def read_bytes(self) -> bytes:
|
|
39
|
+
"""Read the buffer as bytes"""
|
|
40
|
+
raise NotImplementedError()
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def size(self) -> int:
|
|
44
|
+
"""Size of the buffer in bytes"""
|
|
45
|
+
raise NotImplementedError()
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def iterate(self, chunk_size: int) -> Iterable[str]:
|
|
49
|
+
"""Iterate over the buffer in chunks
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
chunk_size: Size of each chunk in bytes
|
|
53
|
+
"""
|
|
54
|
+
raise NotImplementedError()
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def __enter__(self) -> IO:
|
|
58
|
+
raise NotImplementedError()
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
raise NotImplementedError()
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def exists(self) -> bool:
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def materialize_path(self) -> Path:
|
|
70
|
+
raise NotImplementedError
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PathReader(NeatReader):
|
|
74
|
+
def __init__(self, path: Path):
|
|
75
|
+
self.path = path
|
|
76
|
+
self._io: TextIO | None = None
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def name(self) -> str:
|
|
80
|
+
return self.path.name
|
|
81
|
+
|
|
82
|
+
def read_text(self) -> str:
|
|
83
|
+
return self.path.read_text()
|
|
84
|
+
|
|
85
|
+
def read_bytes(self) -> bytes:
|
|
86
|
+
return self.path.read_bytes()
|
|
87
|
+
|
|
88
|
+
def size(self) -> int:
|
|
89
|
+
return self.path.stat().st_size
|
|
90
|
+
|
|
91
|
+
def iterate(self, chunk_size: int) -> Iterable[str]:
|
|
92
|
+
with self.path.open(mode="r") as f:
|
|
93
|
+
while chunk := f.read(chunk_size):
|
|
94
|
+
yield chunk
|
|
95
|
+
|
|
96
|
+
def __enter__(self) -> TextIO:
|
|
97
|
+
file = self.path.open(mode="r")
|
|
98
|
+
self._io = file
|
|
99
|
+
return file
|
|
100
|
+
|
|
101
|
+
def __exit__(self) -> None:
|
|
102
|
+
if self._io:
|
|
103
|
+
self._io.close()
|
|
104
|
+
|
|
105
|
+
def __str__(self) -> str:
|
|
106
|
+
return self.path.as_posix()
|
|
107
|
+
|
|
108
|
+
def exists(self) -> bool:
|
|
109
|
+
return self.path.exists()
|
|
110
|
+
|
|
111
|
+
def materialize_path(self) -> Path:
|
|
112
|
+
return self.path
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class HttpFileReader(NeatReader):
|
|
116
|
+
def __init__(self, url: str, path: str):
|
|
117
|
+
self._url = url
|
|
118
|
+
self.path = path
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def name(self) -> str:
|
|
122
|
+
if "/" in self.path:
|
|
123
|
+
return self.path.rsplit("/", maxsplit=1)[-1]
|
|
124
|
+
return self.path
|
|
125
|
+
|
|
126
|
+
def read_text(self) -> str:
|
|
127
|
+
response = requests.get(self._url)
|
|
128
|
+
response.raise_for_status()
|
|
129
|
+
return response.text
|
|
130
|
+
|
|
131
|
+
def read_bytes(self) -> bytes:
|
|
132
|
+
response = requests.get(self._url)
|
|
133
|
+
response.raise_for_status()
|
|
134
|
+
return response.content
|
|
135
|
+
|
|
136
|
+
def size(self) -> int:
|
|
137
|
+
response = requests.head(self._url)
|
|
138
|
+
response.raise_for_status()
|
|
139
|
+
return int(response.headers["Content-Length"])
|
|
140
|
+
|
|
141
|
+
def iterate(self, chunk_size: int) -> Iterable[str]:
|
|
142
|
+
with requests.get(self._url, stream=True) as response:
|
|
143
|
+
response.raise_for_status()
|
|
144
|
+
for chunk in response.iter_content(chunk_size):
|
|
145
|
+
yield chunk.decode("utf-8")
|
|
146
|
+
|
|
147
|
+
def __enter__(self) -> IO:
|
|
148
|
+
return StringIO(self.read_text())
|
|
149
|
+
|
|
150
|
+
def __str__(self) -> str:
|
|
151
|
+
return self._url
|
|
152
|
+
|
|
153
|
+
def exists(self) -> bool:
|
|
154
|
+
response = requests.head(self._url)
|
|
155
|
+
return 200 <= response.status_code < 400
|
|
156
|
+
|
|
157
|
+
def materialize_path(self) -> Path:
|
|
158
|
+
path = Path(tempfile.gettempdir()).resolve() / "neat" / self.name
|
|
159
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
path.write_bytes(self.read_bytes())
|
|
161
|
+
return path
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class GitHubReader(HttpFileReader):
|
|
165
|
+
raw_url = "https://raw.githubusercontent.com/"
|
|
166
|
+
|
|
167
|
+
def __init__(self, raw: str):
|
|
168
|
+
self.raw = raw
|
|
169
|
+
self.repo, path = self._parse_url(raw)
|
|
170
|
+
super().__init__(f"{self.raw_url}{self.repo}/main/{path}", path)
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _parse_url(url: str) -> tuple[str, str]:
|
|
174
|
+
parsed = urlparse(url)
|
|
175
|
+
if parsed.scheme != "https":
|
|
176
|
+
raise ValueError(f"Unsupported scheme: {parsed.scheme}")
|
|
177
|
+
|
|
178
|
+
path = parsed.path.lstrip("/")
|
|
179
|
+
if parsed.netloc == "github.com":
|
|
180
|
+
repo, path = path.split("/blob/main/", maxsplit=1)
|
|
181
|
+
return repo, path
|
|
182
|
+
|
|
183
|
+
elif parsed.netloc == "api.github.com":
|
|
184
|
+
repo, path = path.removeprefix("repos/").split("/contents/", maxsplit=1)
|
|
185
|
+
return repo, path
|
|
186
|
+
|
|
187
|
+
elif parsed.netloc == "raw.githubusercontent.com":
|
|
188
|
+
repo, path = path.split("/main/", maxsplit=1)
|
|
189
|
+
return repo, path
|
|
190
|
+
|
|
191
|
+
raise ValueError(f"Unsupported netloc: {parsed.netloc}")
|
|
192
|
+
|
|
193
|
+
def __str__(self) -> str:
|
|
194
|
+
return self.raw
|
|
@@ -1,45 +1,39 @@
|
|
|
1
1
|
from ._client import HTTPClient
|
|
2
2
|
from ._data_classes import (
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
ErrorDetails,
|
|
4
|
+
FailedRequestItems,
|
|
5
5
|
FailedRequestMessage,
|
|
6
6
|
FailedResponse,
|
|
7
|
+
FailedResponseItems,
|
|
7
8
|
HTTPMessage,
|
|
8
|
-
|
|
9
|
+
ItemBody,
|
|
10
|
+
ItemIDBody,
|
|
9
11
|
ItemMessage,
|
|
10
|
-
ItemResponse,
|
|
11
12
|
ItemsRequest,
|
|
12
|
-
|
|
13
|
-
ParamRequest,
|
|
13
|
+
ParametersRequest,
|
|
14
14
|
RequestMessage,
|
|
15
15
|
ResponseMessage,
|
|
16
16
|
SimpleBodyRequest,
|
|
17
|
-
SuccessItem,
|
|
18
17
|
SuccessResponse,
|
|
19
|
-
|
|
20
|
-
UnknownRequestItem,
|
|
21
|
-
UnknownResponseItem,
|
|
18
|
+
SuccessResponseItems,
|
|
22
19
|
)
|
|
23
20
|
|
|
24
21
|
__all__ = [
|
|
25
|
-
"
|
|
26
|
-
"
|
|
22
|
+
"ErrorDetails",
|
|
23
|
+
"FailedRequestItems",
|
|
27
24
|
"FailedRequestMessage",
|
|
28
25
|
"FailedResponse",
|
|
26
|
+
"FailedResponseItems",
|
|
29
27
|
"HTTPClient",
|
|
30
28
|
"HTTPMessage",
|
|
31
|
-
"
|
|
29
|
+
"ItemBody",
|
|
30
|
+
"ItemIDBody",
|
|
32
31
|
"ItemMessage",
|
|
33
|
-
"ItemResponse",
|
|
34
32
|
"ItemsRequest",
|
|
35
|
-
"
|
|
36
|
-
"ParamRequest",
|
|
33
|
+
"ParametersRequest",
|
|
37
34
|
"RequestMessage",
|
|
38
35
|
"ResponseMessage",
|
|
39
36
|
"SimpleBodyRequest",
|
|
40
|
-
"SuccessItem",
|
|
41
37
|
"SuccessResponse",
|
|
42
|
-
"
|
|
43
|
-
"UnknownRequestItem",
|
|
44
|
-
"UnknownResponseItem",
|
|
38
|
+
"SuccessResponseItems",
|
|
45
39
|
]
|
|
@@ -8,20 +8,21 @@ from typing import Literal
|
|
|
8
8
|
|
|
9
9
|
import httpx
|
|
10
10
|
from cognite.client import ClientConfig, global_config
|
|
11
|
-
from cognite.client.utils import _json
|
|
12
11
|
|
|
13
12
|
from cognite.neat._utils.auxiliary import get_current_neat_version
|
|
14
13
|
from cognite.neat._utils.http_client._config import get_user_agent
|
|
15
14
|
from cognite.neat._utils.http_client._data_classes import (
|
|
15
|
+
APIResponse,
|
|
16
16
|
BodyRequest,
|
|
17
|
+
ErrorDetails,
|
|
17
18
|
FailedRequestMessage,
|
|
18
19
|
HTTPMessage,
|
|
19
20
|
ItemsRequest,
|
|
20
|
-
|
|
21
|
+
ParametersRequest,
|
|
21
22
|
RequestMessage,
|
|
22
23
|
ResponseMessage,
|
|
23
24
|
)
|
|
24
|
-
from cognite.neat._utils.useful_types import
|
|
25
|
+
from cognite.neat._utils.useful_types import PrimaryTypes
|
|
25
26
|
|
|
26
27
|
if sys.version_info >= (3, 11):
|
|
27
28
|
from typing import Self
|
|
@@ -98,7 +99,7 @@ class HTTPClient:
|
|
|
98
99
|
results = self._handle_error(e, message)
|
|
99
100
|
return results
|
|
100
101
|
|
|
101
|
-
def request_with_retries(self, message: RequestMessage) ->
|
|
102
|
+
def request_with_retries(self, message: RequestMessage) -> APIResponse:
|
|
102
103
|
"""Send an HTTP request and handle retries.
|
|
103
104
|
|
|
104
105
|
This method will keep retrying the request until it either succeeds or
|
|
@@ -118,7 +119,7 @@ class HTTPClient:
|
|
|
118
119
|
raise RuntimeError(f"RequestMessage has already been attempted {message.total_attempts} times.")
|
|
119
120
|
pending_requests: deque[RequestMessage] = deque()
|
|
120
121
|
pending_requests.append(message)
|
|
121
|
-
final_responses
|
|
122
|
+
final_responses = APIResponse()
|
|
122
123
|
|
|
123
124
|
while pending_requests:
|
|
124
125
|
current_request = pending_requests.popleft()
|
|
@@ -157,36 +158,16 @@ class HTTPClient:
|
|
|
157
158
|
headers["Content-Encoding"] = "gzip"
|
|
158
159
|
return headers
|
|
159
160
|
|
|
160
|
-
@staticmethod
|
|
161
|
-
def _prepare_payload(item: BodyRequest) -> str | bytes:
|
|
162
|
-
"""
|
|
163
|
-
Prepare the payload for the HTTP request.
|
|
164
|
-
This method should be overridden in subclasses to customize the payload format.
|
|
165
|
-
"""
|
|
166
|
-
data: str | bytes
|
|
167
|
-
try:
|
|
168
|
-
data = _json.dumps(item.body(), allow_nan=False)
|
|
169
|
-
except ValueError as e:
|
|
170
|
-
# A lot of work to give a more human friendly error message when nans and infs are present:
|
|
171
|
-
msg = "Out of range float values are not JSON compliant"
|
|
172
|
-
if msg in str(e): # exc. might e.g. contain an extra ": nan", depending on build (_json.make_encoder)
|
|
173
|
-
raise ValueError(f"{msg}. Make sure your data does not contain NaN(s) or +/- Inf!").with_traceback(
|
|
174
|
-
e.__traceback__
|
|
175
|
-
) from None
|
|
176
|
-
raise
|
|
177
|
-
|
|
178
|
-
if not global_config.disable_gzip:
|
|
179
|
-
data = gzip.compress(data.encode())
|
|
180
|
-
return data
|
|
181
|
-
|
|
182
161
|
def _make_request(self, item: RequestMessage) -> httpx.Response:
|
|
183
162
|
headers = self._create_headers(item.api_version)
|
|
184
|
-
params: dict[str,
|
|
185
|
-
if isinstance(item,
|
|
163
|
+
params: dict[str, PrimaryTypes] | None = None
|
|
164
|
+
if isinstance(item, ParametersRequest):
|
|
186
165
|
params = item.parameters
|
|
187
166
|
data: str | bytes | None = None
|
|
188
167
|
if isinstance(item, BodyRequest):
|
|
189
|
-
data =
|
|
168
|
+
data = item.data()
|
|
169
|
+
if not global_config.disable_gzip:
|
|
170
|
+
data = gzip.compress(data.encode("utf-8"))
|
|
190
171
|
return self.session.request(
|
|
191
172
|
method=item.method,
|
|
192
173
|
url=item.endpoint_url,
|
|
@@ -202,21 +183,12 @@ class HTTPClient:
|
|
|
202
183
|
response: httpx.Response,
|
|
203
184
|
request: RequestMessage,
|
|
204
185
|
) -> Sequence[HTTPMessage]:
|
|
205
|
-
try:
|
|
206
|
-
body = response.json()
|
|
207
|
-
except ValueError as e:
|
|
208
|
-
return request.create_responses(response, error_message=f"Invalid JSON response: {e!s}")
|
|
209
|
-
|
|
210
|
-
error_obj = body.get("error", {})
|
|
211
|
-
is_auto_retryable = False
|
|
212
|
-
if isinstance(error_obj, dict):
|
|
213
|
-
is_auto_retryable = error_obj.get("isAutoRetryable", False)
|
|
214
|
-
|
|
215
186
|
if 200 <= response.status_code < 300:
|
|
216
|
-
return request.
|
|
217
|
-
|
|
187
|
+
return request.create_success_response(response)
|
|
188
|
+
|
|
189
|
+
if (
|
|
218
190
|
isinstance(request, ItemsRequest)
|
|
219
|
-
and len(request.items) > 1
|
|
191
|
+
and len(request.body.items) > 1
|
|
220
192
|
and response.status_code in self._split_items_status_codes
|
|
221
193
|
):
|
|
222
194
|
# 4XX: Status there is at least one item that is invalid, split the batch to get all valid items processed
|
|
@@ -226,31 +198,20 @@ class HTTPClient:
|
|
|
226
198
|
status_attempts += 1
|
|
227
199
|
splits = request.split(status_attempts=status_attempts)
|
|
228
200
|
if splits[0].tracker and splits[0].tracker.limit_reached():
|
|
229
|
-
return request.
|
|
201
|
+
return request.create_failure_response(response)
|
|
230
202
|
return splits
|
|
231
|
-
|
|
232
|
-
|
|
203
|
+
|
|
204
|
+
error = ErrorDetails.from_response(response)
|
|
205
|
+
|
|
206
|
+
if request.status_attempt < self._max_retries and (
|
|
207
|
+
response.status_code in self._retry_status_codes or error.is_auto_retryable
|
|
233
208
|
):
|
|
234
209
|
request.status_attempt += 1
|
|
235
210
|
time.sleep(self._backoff_time(request.total_attempts))
|
|
236
211
|
return [request]
|
|
237
212
|
else:
|
|
238
213
|
# Permanent failure
|
|
239
|
-
return request.
|
|
240
|
-
|
|
241
|
-
@staticmethod
|
|
242
|
-
def _get_error_message(body: JsonVal, default: str) -> str:
|
|
243
|
-
error = default
|
|
244
|
-
if not isinstance(body, dict):
|
|
245
|
-
return error
|
|
246
|
-
if "error" not in body:
|
|
247
|
-
return error
|
|
248
|
-
error_nested = body["error"]
|
|
249
|
-
if isinstance(error_nested, str):
|
|
250
|
-
return error_nested
|
|
251
|
-
if isinstance(error_nested, dict) and "message" in error_nested and isinstance(error_nested["message"], str):
|
|
252
|
-
return error_nested["message"]
|
|
253
|
-
return error
|
|
214
|
+
return request.create_failure_response(response)
|
|
254
215
|
|
|
255
216
|
@staticmethod
|
|
256
217
|
def _backoff_time(attempts: int) -> float:
|