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.

Files changed (80) hide show
  1. cognite/neat/_client/__init__.py +4 -0
  2. cognite/neat/_client/api.py +8 -0
  3. cognite/neat/_client/client.py +19 -0
  4. cognite/neat/_client/config.py +40 -0
  5. cognite/neat/_client/containers_api.py +73 -0
  6. cognite/neat/_client/data_classes.py +10 -0
  7. cognite/neat/_client/data_model_api.py +63 -0
  8. cognite/neat/_client/spaces_api.py +67 -0
  9. cognite/neat/_client/views_api.py +82 -0
  10. cognite/neat/_data_model/_analysis.py +127 -0
  11. cognite/neat/_data_model/_constants.py +59 -0
  12. cognite/neat/_data_model/_shared.py +46 -0
  13. cognite/neat/_data_model/deployer/__init__.py +0 -0
  14. cognite/neat/_data_model/deployer/_differ.py +113 -0
  15. cognite/neat/_data_model/deployer/_differ_container.py +354 -0
  16. cognite/neat/_data_model/deployer/_differ_data_model.py +29 -0
  17. cognite/neat/_data_model/deployer/_differ_space.py +9 -0
  18. cognite/neat/_data_model/deployer/_differ_view.py +194 -0
  19. cognite/neat/_data_model/deployer/data_classes.py +176 -0
  20. cognite/neat/_data_model/exporters/__init__.py +4 -0
  21. cognite/neat/_data_model/exporters/_base.py +22 -0
  22. cognite/neat/_data_model/exporters/_table_exporter/__init__.py +0 -0
  23. cognite/neat/_data_model/exporters/_table_exporter/exporter.py +106 -0
  24. cognite/neat/_data_model/exporters/_table_exporter/workbook.py +414 -0
  25. cognite/neat/_data_model/exporters/_table_exporter/writer.py +391 -0
  26. cognite/neat/_data_model/importers/__init__.py +2 -1
  27. cognite/neat/_data_model/importers/_api_importer.py +88 -0
  28. cognite/neat/_data_model/importers/_table_importer/data_classes.py +48 -8
  29. cognite/neat/_data_model/importers/_table_importer/importer.py +102 -6
  30. cognite/neat/_data_model/importers/_table_importer/reader.py +860 -0
  31. cognite/neat/_data_model/models/dms/__init__.py +19 -1
  32. cognite/neat/_data_model/models/dms/_base.py +12 -8
  33. cognite/neat/_data_model/models/dms/_constants.py +1 -1
  34. cognite/neat/_data_model/models/dms/_constraints.py +2 -1
  35. cognite/neat/_data_model/models/dms/_container.py +5 -5
  36. cognite/neat/_data_model/models/dms/_data_model.py +3 -3
  37. cognite/neat/_data_model/models/dms/_data_types.py +8 -1
  38. cognite/neat/_data_model/models/dms/_http.py +18 -0
  39. cognite/neat/_data_model/models/dms/_indexes.py +2 -1
  40. cognite/neat/_data_model/models/dms/_references.py +17 -4
  41. cognite/neat/_data_model/models/dms/_space.py +11 -7
  42. cognite/neat/_data_model/models/dms/_view_property.py +7 -4
  43. cognite/neat/_data_model/models/dms/_views.py +16 -6
  44. cognite/neat/_data_model/validation/__init__.py +0 -0
  45. cognite/neat/_data_model/validation/_base.py +16 -0
  46. cognite/neat/_data_model/validation/dms/__init__.py +9 -0
  47. cognite/neat/_data_model/validation/dms/_orchestrator.py +68 -0
  48. cognite/neat/_data_model/validation/dms/_validators.py +139 -0
  49. cognite/neat/_exceptions.py +15 -3
  50. cognite/neat/_issues.py +39 -6
  51. cognite/neat/_session/__init__.py +3 -0
  52. cognite/neat/_session/_physical.py +88 -0
  53. cognite/neat/_session/_session.py +34 -25
  54. cognite/neat/_session/_wrappers.py +61 -0
  55. cognite/neat/_state_machine/__init__.py +10 -0
  56. cognite/neat/{_session/_state_machine → _state_machine}/_base.py +11 -1
  57. cognite/neat/_state_machine/_states.py +53 -0
  58. cognite/neat/_store/__init__.py +3 -0
  59. cognite/neat/_store/_provenance.py +55 -0
  60. cognite/neat/_store/_store.py +124 -0
  61. cognite/neat/_utils/_reader.py +194 -0
  62. cognite/neat/_utils/http_client/__init__.py +14 -20
  63. cognite/neat/_utils/http_client/_client.py +22 -61
  64. cognite/neat/_utils/http_client/_data_classes.py +167 -268
  65. cognite/neat/_utils/text.py +6 -0
  66. cognite/neat/_utils/useful_types.py +26 -2
  67. cognite/neat/_version.py +1 -1
  68. cognite/neat/v0/core/_data_model/importers/_rdf/_shared.py +2 -2
  69. cognite/neat/v0/core/_data_model/importers/_spreadsheet2data_model.py +2 -2
  70. cognite/neat/v0/core/_data_model/models/entities/_single_value.py +1 -1
  71. cognite/neat/v0/core/_data_model/models/physical/_unverified.py +1 -1
  72. cognite/neat/v0/core/_data_model/models/physical/_validation.py +2 -2
  73. cognite/neat/v0/core/_data_model/models/physical/_verified.py +3 -3
  74. cognite/neat/v0/core/_data_model/transformers/_converters.py +1 -1
  75. {cognite_neat-0.125.1.dist-info → cognite_neat-0.126.1.dist-info}/METADATA +1 -1
  76. {cognite_neat-0.125.1.dist-info → cognite_neat-0.126.1.dist-info}/RECORD +78 -40
  77. cognite/neat/_session/_state_machine/__init__.py +0 -23
  78. cognite/neat/_session/_state_machine/_states.py +0 -150
  79. {cognite_neat-0.125.1.dist-info → cognite_neat-0.126.1.dist-info}/WHEEL +0 -0
  80. {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
- FailedItem,
4
- FailedRequestItem,
3
+ ErrorDetails,
4
+ FailedRequestItems,
5
5
  FailedRequestMessage,
6
6
  FailedResponse,
7
+ FailedResponseItems,
7
8
  HTTPMessage,
8
- ItemIDMessage,
9
+ ItemBody,
10
+ ItemIDBody,
9
11
  ItemMessage,
10
- ItemResponse,
11
12
  ItemsRequest,
12
- MissingItem,
13
- ParamRequest,
13
+ ParametersRequest,
14
14
  RequestMessage,
15
15
  ResponseMessage,
16
16
  SimpleBodyRequest,
17
- SuccessItem,
18
17
  SuccessResponse,
19
- UnexpectedItem,
20
- UnknownRequestItem,
21
- UnknownResponseItem,
18
+ SuccessResponseItems,
22
19
  )
23
20
 
24
21
  __all__ = [
25
- "FailedItem",
26
- "FailedRequestItem",
22
+ "ErrorDetails",
23
+ "FailedRequestItems",
27
24
  "FailedRequestMessage",
28
25
  "FailedResponse",
26
+ "FailedResponseItems",
29
27
  "HTTPClient",
30
28
  "HTTPMessage",
31
- "ItemIDMessage",
29
+ "ItemBody",
30
+ "ItemIDBody",
32
31
  "ItemMessage",
33
- "ItemResponse",
34
32
  "ItemsRequest",
35
- "MissingItem",
36
- "ParamRequest",
33
+ "ParametersRequest",
37
34
  "RequestMessage",
38
35
  "ResponseMessage",
39
36
  "SimpleBodyRequest",
40
- "SuccessItem",
41
37
  "SuccessResponse",
42
- "UnexpectedItem",
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
- ParamRequest,
21
+ ParametersRequest,
21
22
  RequestMessage,
22
23
  ResponseMessage,
23
24
  )
24
- from cognite.neat._utils.useful_types import JsonVal
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) -> Sequence[ResponseMessage | FailedRequestMessage]:
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: list[ResponseMessage | FailedRequestMessage] = []
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, str] | None = None
185
- if isinstance(item, ParamRequest):
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 = self._prepare_payload(item)
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.create_responses(response, body)
217
- elif (
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.create_responses(response, body, self._get_error_message(body, response.text))
201
+ return request.create_failure_response(response)
230
202
  return splits
231
- elif request.status_attempt < self._max_retries and (
232
- response.status_code in self._retry_status_codes or is_auto_retryable
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.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
214
+ return request.create_failure_response(response)
254
215
 
255
216
  @staticmethod
256
217
  def _backoff_time(attempts: int) -> float: