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

@@ -2,6 +2,13 @@ import inspect
2
2
  from abc import ABC
3
3
  from typing import TypeVar
4
4
 
5
+ from cognite.neat import _version
6
+
7
+
8
+ def get_current_neat_version() -> str:
9
+ return _version.__version__
10
+
11
+
5
12
  T_Cls = TypeVar("T_Cls")
6
13
 
7
14
 
@@ -0,0 +1,45 @@
1
+ from ._client import HTTPClient
2
+ from ._data_classes import (
3
+ FailedItem,
4
+ FailedRequestItem,
5
+ FailedRequestMessage,
6
+ FailedResponse,
7
+ HTTPMessage,
8
+ ItemIDMessage,
9
+ ItemMessage,
10
+ ItemResponse,
11
+ ItemsRequest,
12
+ MissingItem,
13
+ ParamRequest,
14
+ RequestMessage,
15
+ ResponseMessage,
16
+ SimpleBodyRequest,
17
+ SuccessItem,
18
+ SuccessResponse,
19
+ UnexpectedItem,
20
+ UnknownRequestItem,
21
+ UnknownResponseItem,
22
+ )
23
+
24
+ __all__ = [
25
+ "FailedItem",
26
+ "FailedRequestItem",
27
+ "FailedRequestMessage",
28
+ "FailedResponse",
29
+ "HTTPClient",
30
+ "HTTPMessage",
31
+ "ItemIDMessage",
32
+ "ItemMessage",
33
+ "ItemResponse",
34
+ "ItemsRequest",
35
+ "MissingItem",
36
+ "ParamRequest",
37
+ "RequestMessage",
38
+ "ResponseMessage",
39
+ "SimpleBodyRequest",
40
+ "SuccessItem",
41
+ "SuccessResponse",
42
+ "UnexpectedItem",
43
+ "UnknownRequestItem",
44
+ "UnknownResponseItem",
45
+ ]
@@ -0,0 +1,284 @@
1
+ import gzip
2
+ import random
3
+ import sys
4
+ import time
5
+ from collections import deque
6
+ from collections.abc import MutableMapping, Sequence, Set
7
+ from typing import Literal
8
+
9
+ import httpx
10
+ from cognite.client import ClientConfig, global_config
11
+ from cognite.client.utils import _json
12
+
13
+ from cognite.neat._utils.auxiliary import get_current_neat_version
14
+ from cognite.neat._utils.http_client._config import get_user_agent
15
+ from cognite.neat._utils.http_client._data_classes import (
16
+ BodyRequest,
17
+ FailedRequestMessage,
18
+ HTTPMessage,
19
+ ItemsRequest,
20
+ ParamRequest,
21
+ RequestMessage,
22
+ ResponseMessage,
23
+ )
24
+ from cognite.neat._utils.useful_types import JsonVal
25
+
26
+ if sys.version_info >= (3, 11):
27
+ from typing import Self
28
+ else:
29
+ from typing_extensions import Self
30
+
31
+
32
+ class HTTPClient:
33
+ """An HTTP client.
34
+
35
+ This class handles rate limiting, retries, and error handling for HTTP requests.
36
+
37
+ Args:
38
+ config (ClientConfig): Configuration for the client.
39
+ pool_connections (int): The number of connection pools to cache. Default is 10.
40
+ pool_maxsize (int): The maximum number of connections to save in the pool. Default
41
+ is 20.
42
+ max_retries (int): The maximum number of retries for a request. Default is 10.
43
+ retry_status_codes (frozenset[int]): HTTP status codes that should trigger a retry.
44
+ Default is {408, 429, 502, 503, 504}.
45
+ split_items_status_codes (frozenset[int]): In the case of ItemRequest with multiple
46
+ items, these status codes will trigger splitting the request into smaller batches.
47
+
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ config: ClientConfig,
53
+ max_retries: int = 10,
54
+ pool_connections: int = 10,
55
+ pool_maxsize: int = 20,
56
+ retry_status_codes: Set[int] = frozenset({429, 502, 503, 504}),
57
+ split_items_status_codes: Set[int] = frozenset({400, 408, 409, 422, 502, 503, 504}),
58
+ ):
59
+ self.config = config
60
+ self._max_retries = max_retries
61
+ self._pool_connections = pool_connections
62
+ self._pool_maxsize = pool_maxsize
63
+ self._retry_status_codes = retry_status_codes
64
+ self._split_items_status_codes = split_items_status_codes
65
+
66
+ # Thread-safe session for connection pooling
67
+ self.session = self._create_thread_safe_session()
68
+
69
+ def __enter__(self) -> Self:
70
+ return self
71
+
72
+ def __exit__(
73
+ self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: object | None
74
+ ) -> Literal[False]:
75
+ """Close the session when exiting the context."""
76
+ self.session.close()
77
+ return False # Do not suppress exceptions
78
+
79
+ def request(self, message: RequestMessage) -> Sequence[HTTPMessage]:
80
+ """Send an HTTP request and return the response.
81
+
82
+ Args:
83
+ message (RequestMessage): The request message to send.
84
+
85
+ Returns:
86
+ Sequence[HTTPMessage]: The response message(s). This can also
87
+ include RequestMessage(s) to be retried.
88
+ """
89
+ if isinstance(message, ItemsRequest) and message.tracker and message.tracker.limit_reached():
90
+ error_msg = (
91
+ f"Aborting further splitting of requests after {message.tracker.failed_split_count} failed attempts."
92
+ )
93
+ return message.create_failed_request(error_msg)
94
+ try:
95
+ response = self._make_request(message)
96
+ results = self._handle_response(response, message)
97
+ except Exception as e:
98
+ results = self._handle_error(e, message)
99
+ return results
100
+
101
+ def request_with_retries(self, message: RequestMessage) -> Sequence[ResponseMessage | FailedRequestMessage]:
102
+ """Send an HTTP request and handle retries.
103
+
104
+ This method will keep retrying the request until it either succeeds or
105
+ exhausts the maximum number of retries.
106
+
107
+ Note this method will use the current thread to process all request, thus
108
+ it is blocking.
109
+
110
+ Args:
111
+ message (RequestMessage): The request message to send.
112
+
113
+ Returns:
114
+ Sequence[ResponseMessage | FailedRequestMessage]: The final response
115
+ messages, which can be either successful responses or failed requests.
116
+ """
117
+ if message.total_attempts > 0:
118
+ raise RuntimeError(f"RequestMessage has already been attempted {message.total_attempts} times.")
119
+ pending_requests: deque[RequestMessage] = deque()
120
+ pending_requests.append(message)
121
+ final_responses: list[ResponseMessage | FailedRequestMessage] = []
122
+
123
+ while pending_requests:
124
+ current_request = pending_requests.popleft()
125
+ results = self.request(current_request)
126
+
127
+ for result in results:
128
+ if isinstance(result, RequestMessage):
129
+ pending_requests.append(result)
130
+ elif isinstance(result, ResponseMessage | FailedRequestMessage):
131
+ final_responses.append(result)
132
+ else:
133
+ raise TypeError(f"Unexpected result type: {type(result)}")
134
+
135
+ return final_responses
136
+
137
+ def _create_thread_safe_session(self) -> httpx.Client:
138
+ return httpx.Client(
139
+ limits=httpx.Limits(
140
+ max_connections=self._pool_maxsize,
141
+ max_keepalive_connections=self._pool_connections,
142
+ ),
143
+ timeout=self.config.timeout,
144
+ )
145
+
146
+ def _create_headers(self, api_version: str | None = None) -> MutableMapping[str, str]:
147
+ headers: MutableMapping[str, str] = {}
148
+ headers["User-Agent"] = f"httpx/{httpx.__version__} {get_user_agent()}"
149
+ auth_name, auth_value = self.config.credentials.authorization_header()
150
+ headers[auth_name] = auth_value
151
+ headers["content-type"] = "application/json"
152
+ headers["accept"] = "application/json"
153
+ headers["x-cdp-sdk"] = f"CogniteNeat:{get_current_neat_version()}"
154
+ headers["x-cdp-app"] = self.config.client_name
155
+ headers["cdf-version"] = api_version or self.config.api_subversion
156
+ if not global_config.disable_gzip:
157
+ headers["Content-Encoding"] = "gzip"
158
+ return headers
159
+
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
+ def _make_request(self, item: RequestMessage) -> httpx.Response:
183
+ headers = self._create_headers(item.api_version)
184
+ params: dict[str, str] | None = None
185
+ if isinstance(item, ParamRequest):
186
+ params = item.parameters
187
+ data: str | bytes | None = None
188
+ if isinstance(item, BodyRequest):
189
+ data = self._prepare_payload(item)
190
+ return self.session.request(
191
+ method=item.method,
192
+ url=item.endpoint_url,
193
+ content=data,
194
+ headers=headers,
195
+ params=params,
196
+ timeout=self.config.timeout,
197
+ follow_redirects=False,
198
+ )
199
+
200
+ def _handle_response(
201
+ self,
202
+ response: httpx.Response,
203
+ request: RequestMessage,
204
+ ) -> 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
+ if 200 <= response.status_code < 300:
216
+ return request.create_responses(response, body)
217
+ elif (
218
+ isinstance(request, ItemsRequest)
219
+ and len(request.items) > 1
220
+ and response.status_code in self._split_items_status_codes
221
+ ):
222
+ # 4XX: Status there is at least one item that is invalid, split the batch to get all valid items processed
223
+ # 5xx: Server error, split to reduce the number of items in each request, and count as a status attempt
224
+ status_attempts = request.status_attempt
225
+ if 500 <= response.status_code < 600:
226
+ status_attempts += 1
227
+ splits = request.split(status_attempts=status_attempts)
228
+ if splits[0].tracker and splits[0].tracker.limit_reached():
229
+ return request.create_responses(response, body, self._get_error_message(body, response.text))
230
+ return splits
231
+ elif request.status_attempt < self._max_retries and (
232
+ response.status_code in self._retry_status_codes or is_auto_retryable
233
+ ):
234
+ request.status_attempt += 1
235
+ time.sleep(self._backoff_time(request.total_attempts))
236
+ return [request]
237
+ else:
238
+ # Permanent failure
239
+ return request.create_responses(response, body, self._get_error_message(body, response.text))
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
254
+
255
+ @staticmethod
256
+ def _backoff_time(attempts: int) -> float:
257
+ backoff_time = 0.5 * (2**attempts)
258
+ return min(backoff_time, global_config.max_retry_backoff) * random.uniform(0, 1.0)
259
+
260
+ def _handle_error(
261
+ self,
262
+ e: Exception,
263
+ request: RequestMessage,
264
+ ) -> Sequence[HTTPMessage]:
265
+ if isinstance(e, httpx.ReadTimeout | httpx.TimeoutException):
266
+ error_type = "read"
267
+ request.read_attempt += 1
268
+ attempts = request.read_attempt
269
+ elif isinstance(e, ConnectionError | httpx.ConnectError | httpx.ConnectTimeout):
270
+ error_type = "connect"
271
+ request.connect_attempt += 1
272
+ attempts = request.connect_attempt
273
+ else:
274
+ error_msg = f"Unexpected exception: {e!s}"
275
+ return request.create_failed_request(error_msg)
276
+
277
+ if attempts <= self._max_retries:
278
+ time.sleep(self._backoff_time(request.total_attempts))
279
+ return [request]
280
+ else:
281
+ # We have already incremented the attempt count, so we subtract 1 here
282
+ error_msg = f"RequestException after {request.total_attempts - 1} attempts ({error_type} error): {e!s}"
283
+
284
+ return request.create_failed_request(error_msg)
@@ -0,0 +1,19 @@
1
+ import functools
2
+ import platform
3
+
4
+ from cognite.neat._utils.auxiliary import get_current_neat_version
5
+
6
+
7
+ @functools.lru_cache(maxsize=1)
8
+ def get_user_agent() -> str:
9
+ neat_version = f"CogniteNeat/{get_current_neat_version()}"
10
+ python_version = (
11
+ f"{platform.python_implementation()}/{platform.python_version()} "
12
+ f"({platform.python_build()};{platform.python_compiler()})"
13
+ )
14
+ os_version_info = [platform.release(), platform.machine(), platform.architecture()[0]]
15
+ os_version_info = [s for s in os_version_info if s] # Ignore empty strings
16
+ os_version_info_str = "-".join(os_version_info)
17
+ operating_system = f"{platform.system()}/{os_version_info_str}"
18
+
19
+ return f"{neat_version} {python_version} {operating_system}"
@@ -0,0 +1,389 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Callable, Sequence
3
+ from dataclasses import dataclass, field
4
+ from typing import Generic, Literal, TypeAlias
5
+
6
+ import httpx
7
+
8
+ from cognite.neat._utils.http_client._tracker import ItemsRequestTracker
9
+ from cognite.neat._utils.useful_types import T_ID, JsonVal
10
+
11
+ StatusCode: TypeAlias = int
12
+
13
+
14
+ @dataclass
15
+ class HTTPMessage:
16
+ """Base class for HTTP messages (requests and responses)"""
17
+
18
+ def dump(self) -> dict[str, JsonVal]:
19
+ """Dumps the message to a JSON serializable dictionary.
20
+
21
+ Returns:
22
+ dict[str, JsonVal]: The message as a dictionary.
23
+ """
24
+ # We avoid using the asdict function as we know we have a shallow structure,
25
+ # and this roughly ~10x faster.
26
+ output = self.__dict__.copy()
27
+ output["type"] = type(self).__name__
28
+ return output
29
+
30
+
31
+ @dataclass
32
+ class FailedRequestMessage(HTTPMessage):
33
+ error: str
34
+
35
+
36
+ @dataclass
37
+ class ResponseMessage(HTTPMessage):
38
+ status_code: StatusCode
39
+
40
+
41
+ @dataclass
42
+ class RequestMessage(HTTPMessage):
43
+ """Base class for HTTP request messages"""
44
+
45
+ endpoint_url: str
46
+ method: Literal["GET", "POST", "PATCH", "DELETE"]
47
+ connect_attempt: int = 0
48
+ read_attempt: int = 0
49
+ status_attempt: int = 0
50
+ api_version: str | None = None
51
+
52
+ @property
53
+ def total_attempts(self) -> int:
54
+ return self.connect_attempt + self.read_attempt + self.status_attempt
55
+
56
+ @abstractmethod
57
+ def create_responses(
58
+ self,
59
+ response: httpx.Response,
60
+ response_body: dict[str, JsonVal] | None = None,
61
+ error_message: str | None = None,
62
+ ) -> Sequence[HTTPMessage]:
63
+ raise NotImplementedError()
64
+
65
+ @abstractmethod
66
+ def create_failed_request(self, error_message: str) -> Sequence[HTTPMessage]:
67
+ raise NotImplementedError()
68
+
69
+
70
+ @dataclass
71
+ class SuccessResponse(ResponseMessage):
72
+ body: dict[str, JsonVal] | None = None
73
+
74
+
75
+ @dataclass
76
+ class FailedResponse(ResponseMessage):
77
+ error: str
78
+ body: dict[str, JsonVal] | None = None
79
+
80
+
81
+ @dataclass
82
+ class SimpleRequest(RequestMessage):
83
+ """Base class for requests with a simple success/fail response structure"""
84
+
85
+ @classmethod
86
+ def create_responses(
87
+ cls,
88
+ response: httpx.Response,
89
+ response_body: dict[str, JsonVal] | None = None,
90
+ error_message: str | None = None,
91
+ ) -> Sequence[ResponseMessage]:
92
+ if 200 <= response.status_code < 300 and error_message is None:
93
+ return [SuccessResponse(status_code=response.status_code, body=response_body)]
94
+ if error_message is None:
95
+ error_message = f"Request failed with status code {response.status_code}"
96
+ return [FailedResponse(status_code=response.status_code, error=error_message, body=response_body)]
97
+
98
+ @classmethod
99
+ def create_failed_request(cls, error_message: str) -> Sequence[HTTPMessage]:
100
+ return [FailedRequestMessage(error=error_message)]
101
+
102
+
103
+ @dataclass
104
+ class BodyRequest(RequestMessage, ABC):
105
+ """Base class for HTTP request messages with a body"""
106
+
107
+ @abstractmethod
108
+ def body(self) -> dict[str, JsonVal]:
109
+ raise NotImplementedError()
110
+
111
+
112
+ @dataclass
113
+ class ParamRequest(SimpleRequest):
114
+ """Base class for HTTP request messages with query parameters"""
115
+
116
+ parameters: dict[str, str] | None = None
117
+
118
+
119
+ @dataclass
120
+ class SimpleBodyRequest(SimpleRequest, BodyRequest):
121
+ body_content: dict[str, JsonVal] = field(default_factory=dict)
122
+
123
+ def body(self) -> dict[str, JsonVal]:
124
+ return self.body_content
125
+
126
+
127
+ @dataclass
128
+ class ItemMessage:
129
+ """Base class for message related to a specific item"""
130
+
131
+ ...
132
+
133
+
134
+ @dataclass
135
+ class ItemIDMessage(Generic[T_ID], ItemMessage, ABC):
136
+ """Base class for message related to a specific item identified by an ID"""
137
+
138
+ id: T_ID
139
+
140
+
141
+ @dataclass
142
+ class ItemResponse(ItemIDMessage, ResponseMessage, ABC): ...
143
+
144
+
145
+ @dataclass
146
+ class SuccessItem(ItemResponse):
147
+ item: JsonVal | None = None
148
+
149
+
150
+ @dataclass
151
+ class FailedItem(ItemResponse):
152
+ error: str
153
+
154
+
155
+ @dataclass
156
+ class MissingItem(ItemResponse): ...
157
+
158
+
159
+ @dataclass
160
+ class UnexpectedItem(ItemResponse):
161
+ item: JsonVal | None = None
162
+
163
+
164
+ @dataclass
165
+ class FailedRequestItem(ItemIDMessage, FailedRequestMessage): ...
166
+
167
+
168
+ @dataclass
169
+ class UnknownRequestItem(ItemMessage, FailedRequestMessage):
170
+ item: JsonVal | None = None
171
+
172
+
173
+ @dataclass
174
+ class UnknownResponseItem(ItemMessage, ResponseMessage):
175
+ error: str
176
+ item: JsonVal | None = None
177
+
178
+
179
+ @dataclass
180
+ class ItemsRequest(Generic[T_ID], BodyRequest):
181
+ """Requests message for endpoints that accept multiple items in a single request.
182
+
183
+ This class provides functionality to split large requests into smaller ones, handle responses for each item,
184
+ and manage errors effectively.
185
+
186
+ Attributes:
187
+ items (list[JsonVal]): The list of items to be sent in the request body.
188
+ extra_body_fields (dict[str, JsonVal]): Additional fields to include in the request body
189
+ as_id (Callable[[JsonVal], T_ID] | None): A function to extract the ID from each item. If None,
190
+ IDs are not used.
191
+ max_failures_before_abort (int): The maximum number of failed split requests before aborting further splits.
192
+
193
+ """
194
+
195
+ items: list[JsonVal] = field(default_factory=list)
196
+ extra_body_fields: dict[str, JsonVal] = field(default_factory=dict)
197
+ as_id: Callable[[JsonVal], T_ID] | None = None
198
+ max_failures_before_abort: int = 50
199
+ tracker: ItemsRequestTracker | None = field(default=None, init=False)
200
+
201
+ def dump(self) -> dict[str, JsonVal]:
202
+ """Dumps the message to a JSON serializable dictionary.
203
+
204
+ This override removes the 'as_id' attribute as it is not serializable.
205
+
206
+ Returns:
207
+ dict[str, JsonVal]: The message as a dictionary.
208
+ """
209
+ output = super().dump()
210
+ if self.as_id is not None:
211
+ # We cannot serialize functions
212
+ del output["as_id"]
213
+ if self.tracker is not None:
214
+ # We cannot serialize the tracker
215
+ del output["tracker"]
216
+ return output
217
+
218
+ def body(self) -> dict[str, JsonVal]:
219
+ if self.extra_body_fields:
220
+ return {"items": self.items, **self.extra_body_fields}
221
+ return {"items": self.items}
222
+
223
+ def split(self, status_attempts: int) -> "list[ItemsRequest]":
224
+ """Splits the request into two smaller requests.
225
+
226
+ This is useful for retrying requests that fail due to size limits or timeouts.
227
+
228
+ Args:
229
+ status_attempts: The number of status attempts to set for the new requests. This is used when the
230
+ request failed with a 5xx status code and we want to track the number of attempts. For 4xx errors,
231
+ there is at least one item causing the error, so we do not increment the status attempts, but
232
+ instead essentially do a binary search to find the problematic item(s).
233
+
234
+ Returns:
235
+ A list containing two new ItemsRequest instances, each with half of the original items.
236
+
237
+ """
238
+ mid = len(self.items) // 2
239
+ if mid == 0:
240
+ return [self]
241
+ tracker = self.tracker or ItemsRequestTracker(self.max_failures_before_abort)
242
+ tracker.register_failure()
243
+ first_half = ItemsRequest[T_ID](
244
+ endpoint_url=self.endpoint_url,
245
+ method=self.method,
246
+ items=self.items[:mid],
247
+ extra_body_fields=self.extra_body_fields,
248
+ as_id=self.as_id,
249
+ connect_attempt=self.connect_attempt,
250
+ read_attempt=self.read_attempt,
251
+ status_attempt=status_attempts,
252
+ )
253
+ first_half.tracker = tracker
254
+ second_half = ItemsRequest[T_ID](
255
+ endpoint_url=self.endpoint_url,
256
+ method=self.method,
257
+ items=self.items[mid:],
258
+ extra_body_fields=self.extra_body_fields,
259
+ as_id=self.as_id,
260
+ connect_attempt=self.connect_attempt,
261
+ read_attempt=self.read_attempt,
262
+ status_attempt=status_attempts,
263
+ )
264
+ second_half.tracker = tracker
265
+ return [first_half, second_half]
266
+
267
+ def create_responses(
268
+ self,
269
+ response: httpx.Response,
270
+ response_body: dict[str, JsonVal] | None = None,
271
+ error_message: str | None = None,
272
+ ) -> Sequence[HTTPMessage]:
273
+ """Creates response messages based on the HTTP response and the original request.
274
+
275
+ Args:
276
+ response: The HTTP response received from the server.
277
+ response_body: The parsed JSON body of the response, if available.
278
+ error_message: An optional error message to use if the response indicates a failure.
279
+
280
+ Returns:
281
+ A sequence of HTTPMessage instances representing the outcome for each item in the request.
282
+ """
283
+ if self.as_id is None:
284
+ return SimpleBodyRequest.create_responses(response, response_body, error_message)
285
+ request_items_by_id, errors = self._create_items_by_id()
286
+ responses: list[HTTPMessage] = list(errors)
287
+ error_message = error_message or "Unknown error"
288
+
289
+ if not self._is_items_response(response_body):
290
+ return self._handle_non_items_response(responses, response, error_message, request_items_by_id)
291
+
292
+ # Process items from response
293
+ if response_body is not None:
294
+ self._process_response_items(responses, response, response_body, error_message, request_items_by_id)
295
+
296
+ # Handle missing items
297
+ self._handle_missing_items(responses, response, request_items_by_id)
298
+
299
+ return responses
300
+
301
+ @staticmethod
302
+ def _handle_non_items_response(
303
+ responses: list[HTTPMessage],
304
+ response: httpx.Response,
305
+ error_message: str,
306
+ request_items_by_id: dict[T_ID, JsonVal],
307
+ ) -> list[HTTPMessage]:
308
+ """Handles responses that do not contain an 'items' field in the body."""
309
+ if 200 <= response.status_code < 300:
310
+ responses.extend(
311
+ SuccessItem(status_code=response.status_code, id=id_) for id_ in request_items_by_id.keys()
312
+ )
313
+ else:
314
+ responses.extend(
315
+ FailedItem(status_code=response.status_code, error=error_message, id=id_)
316
+ for id_ in request_items_by_id.keys()
317
+ )
318
+ return responses
319
+
320
+ def _process_response_items(
321
+ self,
322
+ responses: list[HTTPMessage],
323
+ response: httpx.Response,
324
+ response_body: dict[str, JsonVal],
325
+ error_message: str,
326
+ request_items_by_id: dict[T_ID, JsonVal],
327
+ ) -> None:
328
+ """Processes each item in the response body and categorizes them based on their status."""
329
+ for response_item in response_body["items"]: # type: ignore[union-attr]
330
+ try:
331
+ item_id = self.as_id(response_item) # type: ignore[misc]
332
+ except Exception as e:
333
+ responses.append(
334
+ UnknownResponseItem(
335
+ status_code=response.status_code, item=response_item, error=f"Error extracting ID: {e!s}"
336
+ )
337
+ )
338
+ continue
339
+ request_item = request_items_by_id.pop(item_id, None)
340
+ if request_item is None:
341
+ responses.append(UnexpectedItem(status_code=response.status_code, id=item_id, item=response_item))
342
+ elif 200 <= response.status_code < 300:
343
+ responses.append(SuccessItem(status_code=response.status_code, id=item_id, item=response_item))
344
+ else:
345
+ responses.append(FailedItem(status_code=response.status_code, id=item_id, error=error_message))
346
+
347
+ @staticmethod
348
+ def _handle_missing_items(
349
+ responses: list[HTTPMessage], response: httpx.Response, request_items_by_id: dict[T_ID, JsonVal]
350
+ ) -> None:
351
+ """Handles items that were in the request but not present in the response."""
352
+ for item_id in request_items_by_id.keys():
353
+ responses.append(MissingItem(status_code=response.status_code, id=item_id))
354
+
355
+ def create_failed_request(self, error_message: str) -> Sequence[HTTPMessage]:
356
+ if self.as_id is None:
357
+ return SimpleBodyRequest.create_failed_request(error_message)
358
+ items_by_id, errors = self._create_items_by_id()
359
+ results: list[HTTPMessage] = []
360
+ results.extend(errors)
361
+ results.extend(FailedRequestItem(id=item_id, error=error_message) for item_id in items_by_id.keys())
362
+ return results
363
+
364
+ def _create_items_by_id(self) -> tuple[dict[T_ID, JsonVal], list[FailedRequestItem | UnknownRequestItem]]:
365
+ if self.as_id is None:
366
+ raise ValueError("as_id function must be provided to create items by ID")
367
+ items_by_id: dict[T_ID, JsonVal] = {}
368
+ errors: list[FailedRequestItem | UnknownRequestItem] = []
369
+ for item in self.items:
370
+ try:
371
+ item_id = self.as_id(item)
372
+ except Exception as e:
373
+ errors.append(UnknownRequestItem(error=f"Error extracting ID: {e!s}", item=item))
374
+ continue
375
+ if item_id in items_by_id:
376
+ errors.append(FailedRequestItem(id=item_id, error=f"Duplicate item ID: {item_id!r}"))
377
+ else:
378
+ items_by_id[item_id] = item
379
+ return items_by_id, errors
380
+
381
+ @staticmethod
382
+ def _is_items_response(body: dict[str, JsonVal] | None = None) -> bool:
383
+ if body is None:
384
+ return False
385
+ if "items" not in body:
386
+ return False
387
+ if not isinstance(body["items"], list):
388
+ return False
389
+ return True
@@ -0,0 +1,31 @@
1
+ import threading
2
+ from dataclasses import dataclass, field
3
+
4
+
5
+ @dataclass
6
+ class ItemsRequestTracker:
7
+ """Tracks the state of requests split from an original request.
8
+
9
+ Attributes:
10
+ max_failures_before_abort (int): Maximum number of allowed failed split requests before aborting
11
+ the entire operation. A value of -1 indicates no early abort.
12
+ lock (threading.Lock): A lock to ensure thread-safe updates to the failure count.
13
+ failed_split_count (int): The current count of failed split requests.
14
+
15
+ """
16
+
17
+ max_failures_before_abort: int = -1 # -1 means no early abort
18
+ lock: threading.Lock = field(default_factory=threading.Lock, init=False)
19
+ failed_split_count: int = field(default=0, init=False)
20
+
21
+ def register_failure(self) -> None:
22
+ """Register a failed split request and return whether to continue splitting."""
23
+ with self.lock:
24
+ self.failed_split_count += 1
25
+
26
+ def limit_reached(self) -> bool:
27
+ """Check if the failure limit has been reached."""
28
+ with self.lock:
29
+ if self.max_failures_before_abort < 0:
30
+ return False
31
+ return self.failed_split_count >= self.max_failures_before_abort
@@ -0,0 +1,7 @@
1
+ from collections.abc import Hashable
2
+ from typing import TypeAlias, TypeVar
3
+
4
+ JsonVal: TypeAlias = None | str | int | float | bool | dict[str, "JsonVal"] | list["JsonVal"]
5
+
6
+
7
+ T_ID = TypeVar("T_ID", bound=Hashable)
cognite/neat/_version.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "0.123.31"
1
+ __version__ = "0.123.33"
2
2
  __engine__ = "^2.0.4"
@@ -579,6 +579,13 @@ class EdgeEntity(PhysicalEntity[None]):
579
579
  properties: ViewEntity | None = None
580
580
  direction: Literal["outwards", "inwards"] = "outwards"
581
581
 
582
+ @field_validator("direction", mode="before")
583
+ @classmethod
584
+ def _normalize_direction(cls, value: Any) -> str:
585
+ if isinstance(value, str):
586
+ return value.lower()
587
+ return value
588
+
582
589
  def dump(self, **defaults: Any) -> str:
583
590
  # Add default direction
584
591
  return super().dump(**defaults, direction="outwards")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cognite-neat
3
- Version: 0.123.31
3
+ Version: 0.123.33
4
4
  Summary: Knowledge graph transformation
5
5
  Project-URL: Documentation, https://cognite-neat.readthedocs-hosted.com/
6
6
  Project-URL: Homepage, https://cognite-neat.readthedocs-hosted.com/
@@ -14,6 +14,7 @@ Requires-Dist: backports-strenum<2.0.0,>=1.2; python_version < '3.11'
14
14
  Requires-Dist: cognite-sdk<8.0.0,>=7.83.0
15
15
  Requires-Dist: elementpath<5.0.0,>=4.0.0
16
16
  Requires-Dist: exceptiongroup<2.0.0,>=1.1.3; python_version < '3.11'
17
+ Requires-Dist: httpx>=0.28.1
17
18
  Requires-Dist: jsonpath-python<2.0.0,>=1.0.6
18
19
  Requires-Dist: mixpanel<5.0.0,>=4.10.1
19
20
  Requires-Dist: networkx<4.0.0,>=3.4.2
@@ -1,5 +1,5 @@
1
1
  cognite/neat/__init__.py,sha256=12StS1dzH9_MElqxGvLWrNsxCJl9Hv8A2a9D0E5OD_U,193
2
- cognite/neat/_version.py,sha256=c-BbCQozGLPXIz0KlrtSttiQDSYohkj9MbUXKI693Pc,47
2
+ cognite/neat/_version.py,sha256=Xav6QPCXfBck5Z-fBa2tahX3WhT0F8za8OJ5ijeqSwM,47
3
3
  cognite/neat/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  cognite/neat/_data_model/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  cognite/neat/_data_model/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -9,7 +9,13 @@ cognite/neat/_data_model/models/dms/_constants.py,sha256=TpnOZ5Q1O_r2H5Ez3sAvaCH
9
9
  cognite/neat/_data_model/models/dms/_space.py,sha256=W5tRG3GIcxRK9RBkpWWtZWdaqUZPiKq-NMOwPuHjj9o,1677
10
10
  cognite/neat/_data_model/models/entities/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  cognite/neat/_utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- cognite/neat/_utils/auxiliary.py,sha256=szvIrFRfBSZ_CpF24z5I1ustJohdvGZjdi_7TFB6Ltc,1236
12
+ cognite/neat/_utils/auxiliary.py,sha256=Cx-LP8dfN782R3iUcm--q26zdzQ0k_RFnVbJ0bwVZMI,1345
13
+ cognite/neat/_utils/useful_types.py,sha256=6Fpw_HlWFj8ZYjGPd0KzguBczuI8GFhj5wBceGsaeak,211
14
+ cognite/neat/_utils/http_client/__init__.py,sha256=gJBrOH1tIzEzLforHbeakYimTn4RlelyANps-jtpREI,894
15
+ cognite/neat/_utils/http_client/_client.py,sha256=2RVwTbbPFlQ8eJVLKNUXwnc4Yq_783PkY44zwr6LlT8,11509
16
+ cognite/neat/_utils/http_client/_config.py,sha256=C8IF1JoijmVMjA_FEMgAkiD1buEV1cY5Og3t-Ecyfmk,756
17
+ cognite/neat/_utils/http_client/_data_classes.py,sha256=EaIi1L3azqPdjR8H04EvDnCeupbX2rC7F0y8Y9ukap0,13605
18
+ cognite/neat/_utils/http_client/_tracker.py,sha256=EBBnd-JZ7nc_jYNFJokCHN2UZ9sx0McFLZvlceUYYic,1215
13
19
  cognite/neat/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
20
  cognite/neat/core/_config.py,sha256=WT1BS8uADcFvGoUYOOfwFOVq_VBl472TisdoA3wLick,280
15
21
  cognite/neat/core/_constants.py,sha256=wIpGOzZAKS2vhshXR1K51cbcsgq2TlvZaZ3zw91CU9I,9054
@@ -70,7 +76,7 @@ cognite/neat/core/_data_model/models/entities/_constants.py,sha256=GXRzVfArwxF3C
70
76
  cognite/neat/core/_data_model/models/entities/_loaders.py,sha256=vYRHID2SoseyJuzO4sdjjeWaRnv774SdKz9fCeuHTHU,5703
71
77
  cognite/neat/core/_data_model/models/entities/_multi_value.py,sha256=4507L7dr_CL4vo1En6EyMiHhUDOWPTDVR1aYZns6S38,2792
72
78
  cognite/neat/core/_data_model/models/entities/_restrictions.py,sha256=inztyR2EskFK9cYAdm39GbnjAod7Zmg0TL9NI0ZRleM,8925
73
- cognite/neat/core/_data_model/models/entities/_single_value.py,sha256=HWANjeBNDo_o3s3oHxRWsxtidvo3d40GObcm7H_7hKo,23753
79
+ cognite/neat/core/_data_model/models/entities/_single_value.py,sha256=U2Fd_qB2HzD4yvSZLQsH663XMXMJRj_zv1z_w1ZTVh0,23963
74
80
  cognite/neat/core/_data_model/models/entities/_types.py,sha256=MqrCovqI_nvpMB4UqiUk4eUlKANvr8P7wr8k3y8lXlQ,2183
75
81
  cognite/neat/core/_data_model/models/entities/_wrapped.py,sha256=hOvdyxCNFgv1UdfaasviKnbEN4yN09Iip0ggQiaXgB4,7993
76
82
  cognite/neat/core/_data_model/models/mapping/__init__.py,sha256=T68Hf7rhiXa7b03h4RMwarAmkGnB-Bbhc1H07b2PyC4,100
@@ -203,7 +209,7 @@ cognite/neat/session/engine/__init__.py,sha256=D3MxUorEs6-NtgoICqtZ8PISQrjrr4dvc
203
209
  cognite/neat/session/engine/_import.py,sha256=1QxA2_EK613lXYAHKQbZyw2yjo5P9XuiX4Z6_6-WMNQ,169
204
210
  cognite/neat/session/engine/_interface.py,sha256=3W-cYr493c_mW3P5O6MKN1xEQg3cA7NHR_ev3zdF9Vk,533
205
211
  cognite/neat/session/engine/_load.py,sha256=g52uYakQM03VqHt_RDHtpHso1-mFFifH5M4T2ScuH8A,5198
206
- cognite_neat-0.123.31.dist-info/METADATA,sha256=fDMu0gS-R0DVtoE1eFJcyXsniWkamkN_jvc5kSTFx_8,9166
207
- cognite_neat-0.123.31.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
208
- cognite_neat-0.123.31.dist-info/licenses/LICENSE,sha256=W8VmvFia4WHa3Gqxq1Ygrq85McUNqIGDVgtdvzT-XqA,11351
209
- cognite_neat-0.123.31.dist-info/RECORD,,
212
+ cognite_neat-0.123.33.dist-info/METADATA,sha256=4IXHfkYBYFH7XwVcSbSRNfqhj2Yde__03EWEsZ336oA,9195
213
+ cognite_neat-0.123.33.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
214
+ cognite_neat-0.123.33.dist-info/licenses/LICENSE,sha256=W8VmvFia4WHa3Gqxq1Ygrq85McUNqIGDVgtdvzT-XqA,11351
215
+ cognite_neat-0.123.33.dist-info/RECORD,,