cognite-toolkit 0.6.97__py3-none-any.whl → 0.7.30__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. cognite_toolkit/_cdf.py +16 -17
  2. cognite_toolkit/_cdf_tk/apps/__init__.py +2 -0
  3. cognite_toolkit/_cdf_tk/apps/_core_app.py +13 -5
  4. cognite_toolkit/_cdf_tk/apps/_data_app.py +1 -1
  5. cognite_toolkit/_cdf_tk/apps/_dev_app.py +86 -0
  6. cognite_toolkit/_cdf_tk/apps/_download_app.py +692 -24
  7. cognite_toolkit/_cdf_tk/apps/_dump_app.py +43 -101
  8. cognite_toolkit/_cdf_tk/apps/_landing_app.py +18 -4
  9. cognite_toolkit/_cdf_tk/apps/_migrate_app.py +249 -9
  10. cognite_toolkit/_cdf_tk/apps/_modules_app.py +0 -3
  11. cognite_toolkit/_cdf_tk/apps/_purge.py +15 -43
  12. cognite_toolkit/_cdf_tk/apps/_run.py +11 -0
  13. cognite_toolkit/_cdf_tk/apps/_upload_app.py +45 -6
  14. cognite_toolkit/_cdf_tk/builders/__init__.py +2 -2
  15. cognite_toolkit/_cdf_tk/builders/_base.py +28 -42
  16. cognite_toolkit/_cdf_tk/cdf_toml.py +20 -1
  17. cognite_toolkit/_cdf_tk/client/_toolkit_client.py +23 -3
  18. cognite_toolkit/_cdf_tk/client/api/extended_functions.py +6 -9
  19. cognite_toolkit/_cdf_tk/client/api/infield.py +93 -1
  20. cognite_toolkit/_cdf_tk/client/api/migration.py +175 -1
  21. cognite_toolkit/_cdf_tk/client/api/streams.py +84 -0
  22. cognite_toolkit/_cdf_tk/client/api/three_d.py +50 -0
  23. cognite_toolkit/_cdf_tk/client/data_classes/base.py +25 -1
  24. cognite_toolkit/_cdf_tk/client/data_classes/canvas.py +46 -3
  25. cognite_toolkit/_cdf_tk/client/data_classes/charts.py +3 -3
  26. cognite_toolkit/_cdf_tk/client/data_classes/charts_data.py +95 -213
  27. cognite_toolkit/_cdf_tk/client/data_classes/infield.py +32 -18
  28. cognite_toolkit/_cdf_tk/client/data_classes/migration.py +10 -2
  29. cognite_toolkit/_cdf_tk/client/data_classes/streams.py +90 -0
  30. cognite_toolkit/_cdf_tk/client/data_classes/three_d.py +47 -0
  31. cognite_toolkit/_cdf_tk/client/testing.py +18 -2
  32. cognite_toolkit/_cdf_tk/commands/__init__.py +6 -6
  33. cognite_toolkit/_cdf_tk/commands/_changes.py +3 -42
  34. cognite_toolkit/_cdf_tk/commands/_download.py +21 -11
  35. cognite_toolkit/_cdf_tk/commands/_migrate/__init__.py +0 -2
  36. cognite_toolkit/_cdf_tk/commands/_migrate/command.py +22 -20
  37. cognite_toolkit/_cdf_tk/commands/_migrate/conversion.py +133 -91
  38. cognite_toolkit/_cdf_tk/commands/_migrate/data_classes.py +73 -22
  39. cognite_toolkit/_cdf_tk/commands/_migrate/data_mapper.py +311 -43
  40. cognite_toolkit/_cdf_tk/commands/_migrate/default_mappings.py +5 -5
  41. cognite_toolkit/_cdf_tk/commands/_migrate/issues.py +33 -0
  42. cognite_toolkit/_cdf_tk/commands/_migrate/migration_io.py +157 -8
  43. cognite_toolkit/_cdf_tk/commands/_migrate/selectors.py +9 -4
  44. cognite_toolkit/_cdf_tk/commands/_purge.py +27 -28
  45. cognite_toolkit/_cdf_tk/commands/_questionary_style.py +16 -0
  46. cognite_toolkit/_cdf_tk/commands/_upload.py +109 -86
  47. cognite_toolkit/_cdf_tk/commands/about.py +221 -0
  48. cognite_toolkit/_cdf_tk/commands/auth.py +19 -12
  49. cognite_toolkit/_cdf_tk/commands/build_cmd.py +15 -61
  50. cognite_toolkit/_cdf_tk/commands/clean.py +63 -16
  51. cognite_toolkit/_cdf_tk/commands/deploy.py +20 -17
  52. cognite_toolkit/_cdf_tk/commands/dump_resource.py +6 -4
  53. cognite_toolkit/_cdf_tk/commands/init.py +225 -3
  54. cognite_toolkit/_cdf_tk/commands/modules.py +20 -44
  55. cognite_toolkit/_cdf_tk/commands/pull.py +6 -19
  56. cognite_toolkit/_cdf_tk/commands/resources.py +179 -0
  57. cognite_toolkit/_cdf_tk/constants.py +20 -1
  58. cognite_toolkit/_cdf_tk/cruds/__init__.py +19 -5
  59. cognite_toolkit/_cdf_tk/cruds/_base_cruds.py +14 -70
  60. cognite_toolkit/_cdf_tk/cruds/_data_cruds.py +8 -17
  61. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/__init__.py +4 -1
  62. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/agent.py +11 -9
  63. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/auth.py +4 -14
  64. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/classic.py +44 -43
  65. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/configuration.py +4 -11
  66. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/data_organization.py +4 -13
  67. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/datamodel.py +205 -66
  68. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/extraction_pipeline.py +5 -17
  69. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/fieldops.py +116 -27
  70. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/file.py +6 -27
  71. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/function.py +9 -28
  72. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/hosted_extractors.py +12 -30
  73. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/industrial_tool.py +3 -7
  74. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/location.py +3 -15
  75. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/migration.py +4 -12
  76. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/raw.py +4 -10
  77. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/relationship.py +3 -8
  78. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/robotics.py +15 -44
  79. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/streams.py +94 -0
  80. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/three_d_model.py +3 -7
  81. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/timeseries.py +5 -15
  82. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/transformation.py +39 -31
  83. cognite_toolkit/_cdf_tk/cruds/_resource_cruds/workflow.py +20 -40
  84. cognite_toolkit/_cdf_tk/cruds/_worker.py +24 -36
  85. cognite_toolkit/_cdf_tk/feature_flags.py +16 -36
  86. cognite_toolkit/_cdf_tk/plugins.py +2 -1
  87. cognite_toolkit/_cdf_tk/resource_classes/__init__.py +4 -0
  88. cognite_toolkit/_cdf_tk/resource_classes/capabilities.py +12 -0
  89. cognite_toolkit/_cdf_tk/resource_classes/functions.py +3 -1
  90. cognite_toolkit/_cdf_tk/resource_classes/infield_cdm_location_config.py +109 -0
  91. cognite_toolkit/_cdf_tk/resource_classes/migration.py +8 -17
  92. cognite_toolkit/_cdf_tk/resource_classes/streams.py +29 -0
  93. cognite_toolkit/_cdf_tk/storageio/__init__.py +9 -21
  94. cognite_toolkit/_cdf_tk/storageio/_annotations.py +19 -16
  95. cognite_toolkit/_cdf_tk/storageio/_applications.py +338 -26
  96. cognite_toolkit/_cdf_tk/storageio/_asset_centric.py +67 -104
  97. cognite_toolkit/_cdf_tk/storageio/_base.py +61 -29
  98. cognite_toolkit/_cdf_tk/storageio/_datapoints.py +276 -20
  99. cognite_toolkit/_cdf_tk/storageio/_file_content.py +436 -0
  100. cognite_toolkit/_cdf_tk/storageio/_instances.py +34 -2
  101. cognite_toolkit/_cdf_tk/storageio/_raw.py +26 -0
  102. cognite_toolkit/_cdf_tk/storageio/selectors/__init__.py +62 -4
  103. cognite_toolkit/_cdf_tk/storageio/selectors/_base.py +14 -2
  104. cognite_toolkit/_cdf_tk/storageio/selectors/_canvas.py +14 -0
  105. cognite_toolkit/_cdf_tk/storageio/selectors/_charts.py +14 -0
  106. cognite_toolkit/_cdf_tk/storageio/selectors/_datapoints.py +23 -3
  107. cognite_toolkit/_cdf_tk/storageio/selectors/_file_content.py +164 -0
  108. cognite_toolkit/_cdf_tk/tk_warnings/other.py +4 -0
  109. cognite_toolkit/_cdf_tk/tracker.py +2 -2
  110. cognite_toolkit/_cdf_tk/utils/dtype_conversion.py +9 -3
  111. cognite_toolkit/_cdf_tk/utils/fileio/__init__.py +2 -0
  112. cognite_toolkit/_cdf_tk/utils/fileio/_base.py +5 -1
  113. cognite_toolkit/_cdf_tk/utils/fileio/_readers.py +112 -20
  114. cognite_toolkit/_cdf_tk/utils/fileio/_writers.py +15 -15
  115. cognite_toolkit/_cdf_tk/utils/http_client/_client.py +284 -18
  116. cognite_toolkit/_cdf_tk/utils/http_client/_data_classes.py +50 -4
  117. cognite_toolkit/_cdf_tk/utils/http_client/_data_classes2.py +187 -0
  118. cognite_toolkit/_cdf_tk/utils/interactive_select.py +9 -14
  119. cognite_toolkit/_cdf_tk/utils/sql_parser.py +2 -3
  120. cognite_toolkit/_cdf_tk/utils/useful_types.py +6 -2
  121. cognite_toolkit/_cdf_tk/validation.py +79 -1
  122. cognite_toolkit/_repo_files/GitHub/.github/workflows/deploy.yaml +1 -1
  123. cognite_toolkit/_repo_files/GitHub/.github/workflows/dry-run.yaml +1 -1
  124. cognite_toolkit/_resources/cdf.toml +5 -4
  125. cognite_toolkit/_version.py +1 -1
  126. cognite_toolkit/config.dev.yaml +13 -0
  127. {cognite_toolkit-0.6.97.dist-info → cognite_toolkit-0.7.30.dist-info}/METADATA +24 -24
  128. {cognite_toolkit-0.6.97.dist-info → cognite_toolkit-0.7.30.dist-info}/RECORD +153 -143
  129. cognite_toolkit-0.7.30.dist-info/WHEEL +4 -0
  130. {cognite_toolkit-0.6.97.dist-info → cognite_toolkit-0.7.30.dist-info}/entry_points.txt +1 -0
  131. cognite_toolkit/_cdf_tk/commands/_migrate/canvas.py +0 -201
  132. cognite_toolkit/_cdf_tk/commands/dump_data.py +0 -489
  133. cognite_toolkit/_cdf_tk/commands/featureflag.py +0 -27
  134. cognite_toolkit/_cdf_tk/utils/table_writers.py +0 -434
  135. cognite_toolkit-0.6.97.dist-info/WHEEL +0 -4
  136. cognite_toolkit-0.6.97.dist-info/licenses/LICENSE +0 -18
@@ -4,7 +4,7 @@ import sys
4
4
  import time
5
5
  from collections import deque
6
6
  from collections.abc import MutableMapping, Sequence, Set
7
- from typing import Literal
7
+ from typing import Literal, TypeVar
8
8
 
9
9
  import httpx
10
10
  from cognite.client import global_config
@@ -23,6 +23,20 @@ from cognite_toolkit._cdf_tk.utils.http_client._data_classes import (
23
23
  ResponseList,
24
24
  ResponseMessage,
25
25
  )
26
+ from cognite_toolkit._cdf_tk.utils.http_client._data_classes2 import (
27
+ BaseRequestMessage,
28
+ ErrorDetails2,
29
+ FailedRequest2,
30
+ FailedResponse2,
31
+ HTTPResult2,
32
+ ItemsFailedRequest2,
33
+ ItemsFailedResponse2,
34
+ ItemsRequest2,
35
+ ItemsResultMessage2,
36
+ ItemsSuccessResponse2,
37
+ RequestMessage2,
38
+ SuccessResponse2,
39
+ )
26
40
  from cognite_toolkit._cdf_tk.utils.useful_types import PrimitiveType
27
41
 
28
42
  if sys.version_info >= (3, 11):
@@ -32,6 +46,8 @@ else:
32
46
 
33
47
  from cognite_toolkit._cdf_tk.client.config import ToolkitClientConfig
34
48
 
49
+ _T_Request_Message = TypeVar("_T_Request_Message", bound=BaseRequestMessage)
50
+
35
51
 
36
52
  class HTTPClient:
37
53
  """An HTTP client.
@@ -48,6 +64,7 @@ class HTTPClient:
48
64
  Default is {408, 429, 502, 503, 504}.
49
65
  split_items_status_codes (frozenset[int]): In the case of ItemRequest with multiple
50
66
  items, these status codes will trigger splitting the request into smaller batches.
67
+ console (Console | None): Optional Rich Console for printing warnings.
51
68
 
52
69
  """
53
70
 
@@ -59,6 +76,7 @@ class HTTPClient:
59
76
  pool_maxsize: int = 20,
60
77
  retry_status_codes: Set[int] = frozenset({408, 429, 502, 503, 504}),
61
78
  split_items_status_codes: Set[int] = frozenset({400, 408, 409, 422, 502, 503, 504}),
79
+ console: Console | None = None,
62
80
  ):
63
81
  self.config = config
64
82
  self._max_retries = max_retries
@@ -66,6 +84,7 @@ class HTTPClient:
66
84
  self._pool_maxsize = pool_maxsize
67
85
  self._retry_status_codes = retry_status_codes
68
86
  self._split_items_status_codes = split_items_status_codes
87
+ self._console = console
69
88
 
70
89
  # Thread-safe session for connection pooling
71
90
  self.session = self._create_thread_safe_session()
@@ -80,12 +99,11 @@ class HTTPClient:
80
99
  self.session.close()
81
100
  return False # Do not suppress exceptions
82
101
 
83
- def request(self, message: RequestMessage, console: Console | None = None) -> Sequence[HTTPMessage]:
102
+ def request(self, message: RequestMessage) -> Sequence[HTTPMessage]:
84
103
  """Send an HTTP request and return the response.
85
104
 
86
105
  Args:
87
106
  message (RequestMessage): The request message to send.
88
- console (Console | None): The rich console to use for printing warnings.
89
107
 
90
108
  Returns:
91
109
  Sequence[HTTPMessage]: The response message(s). This can also
@@ -98,12 +116,12 @@ class HTTPClient:
98
116
  return message.create_failed_request(error_msg)
99
117
  try:
100
118
  response = self._make_request(message)
101
- results = self._handle_response(response, message, console)
119
+ results = self._handle_response(response, message)
102
120
  except Exception as e:
103
121
  results = self._handle_error(e, message)
104
122
  return results
105
123
 
106
- def request_with_retries(self, message: RequestMessage, console: Console | None = None) -> ResponseList:
124
+ def request_with_retries(self, message: RequestMessage) -> ResponseList:
107
125
  """Send an HTTP request and handle retries.
108
126
 
109
127
  This method will keep retrying the request until it either succeeds or
@@ -114,7 +132,6 @@ class HTTPClient:
114
132
 
115
133
  Args:
116
134
  message (RequestMessage): The request message to send.
117
- console (Console | None): The rich console to use for printing warnings.
118
135
 
119
136
  Returns:
120
137
  Sequence[ResponseMessage | FailedRequestMessage]: The final response
@@ -127,7 +144,7 @@ class HTTPClient:
127
144
  final_responses = ResponseList([])
128
145
  while pending_requests:
129
146
  current_request = pending_requests.popleft()
130
- results = self.request(current_request, console)
147
+ results = self.request(current_request)
131
148
 
132
149
  for result in results:
133
150
  if isinstance(result, RequestMessage):
@@ -149,34 +166,40 @@ class HTTPClient:
149
166
  )
150
167
 
151
168
  def _create_headers(
152
- self, api_version: str | None = None, content_type: str = "application/json", accept: str = "application/json"
169
+ self,
170
+ api_version: str | None = None,
171
+ content_type: str = "application/json",
172
+ accept: str = "application/json",
173
+ content_length: int | None = None,
153
174
  ) -> MutableMapping[str, str]:
154
175
  headers: MutableMapping[str, str] = {}
155
176
  headers["User-Agent"] = f"httpx/{httpx.__version__} {get_user_agent()}"
156
177
  auth_name, auth_value = self.config.credentials.authorization_header()
157
178
  headers[auth_name] = auth_value
158
- headers["content-type"] = content_type
179
+ headers["Content-Type"] = content_type
180
+ if content_length is not None:
181
+ headers["Content-Length"] = str(content_length)
159
182
  headers["accept"] = accept
160
183
  headers["x-cdp-sdk"] = f"CogniteToolkit:{get_current_toolkit_version()}"
161
184
  headers["x-cdp-app"] = self.config.client_name
162
185
  headers["cdf-version"] = api_version or self.config.api_subversion
163
- if not global_config.disable_gzip:
186
+ if not global_config.disable_gzip and content_length is None:
164
187
  headers["Content-Encoding"] = "gzip"
165
188
  return headers
166
189
 
167
190
  def _make_request(self, item: RequestMessage) -> httpx.Response:
168
- headers = self._create_headers(item.api_version, item.content_type, item.accept)
191
+ headers = self._create_headers(item.api_version, item.content_type, item.accept, item.content_length)
169
192
  params: dict[str, PrimitiveType] | None = None
170
193
  if isinstance(item, ParamRequest):
171
194
  params = item.parameters
172
195
  data: str | bytes | None = None
173
196
  if isinstance(item, BodyRequest):
174
197
  data = item.data()
175
- if not global_config.disable_gzip:
198
+ if not global_config.disable_gzip and item.content_length is None:
176
199
  data = gzip.compress(data.encode("utf-8"))
177
200
  elif isinstance(item, DataBodyRequest):
178
201
  data = item.data()
179
- if not global_config.disable_gzip:
202
+ if not global_config.disable_gzip and item.content_length is None:
180
203
  data = gzip.compress(data)
181
204
  return self.session.request(
182
205
  method=item.method,
@@ -188,9 +211,7 @@ class HTTPClient:
188
211
  follow_redirects=False,
189
212
  )
190
213
 
191
- def _handle_response(
192
- self, response: httpx.Response, request: RequestMessage, console: Console | None = None
193
- ) -> Sequence[HTTPMessage]:
214
+ def _handle_response(self, response: httpx.Response, request: RequestMessage) -> Sequence[HTTPMessage]:
194
215
  if 200 <= response.status_code < 300:
195
216
  return request.create_success_response(response)
196
217
  elif (
@@ -210,11 +231,11 @@ class HTTPClient:
210
231
 
211
232
  retry_after = self._get_retry_after_in_header(response)
212
233
  if retry_after is not None and response.status_code == 429 and request.status_attempt < self._max_retries:
213
- if console is not None:
234
+ if self._console is not None:
214
235
  short_url = request.endpoint_url.removeprefix(self.config.base_api_url)
215
236
  HighSeverityWarning(
216
237
  f"Rate limit exceeded for the {short_url!r} endpoint. Retrying after {retry_after} seconds."
217
- ).print_warning(console=console)
238
+ ).print_warning(console=self._console)
218
239
  request.status_attempt += 1
219
240
  time.sleep(retry_after)
220
241
  return [request]
@@ -267,3 +288,248 @@ class HTTPClient:
267
288
  error_msg = f"RequestException after {request.total_attempts - 1} attempts ({error_type} error): {e!s}"
268
289
 
269
290
  return request.create_failed_request(error_msg)
291
+
292
+ def request_single(self, message: RequestMessage2) -> RequestMessage2 | HTTPResult2:
293
+ """Send an HTTP request and return the response.
294
+
295
+ Args:
296
+ message (RequestMessage2): The request message to send.
297
+ Returns:
298
+ HTTPMessage: The response message.
299
+ """
300
+ try:
301
+ response = self._make_request2(message)
302
+ result = self._handle_response_single(response, message)
303
+ except Exception as e:
304
+ result = self._handle_error_single(e, message)
305
+ return result
306
+
307
+ def request_single_retries(self, message: RequestMessage2) -> HTTPResult2:
308
+ """Send an HTTP request and handle retries.
309
+
310
+ This method will keep retrying the request until it either succeeds or
311
+ exhausts the maximum number of retries.
312
+
313
+ Note this method will use the current thread to process all request, thus
314
+ it is blocking.
315
+
316
+ Args:
317
+ message (RequestMessage2): The request message to send.
318
+ Returns:
319
+ HTTPMessage2: The final response message, which can be either successful response or failed request.
320
+ """
321
+ if message.total_attempts > 0:
322
+ raise RuntimeError(f"RequestMessage has already been attempted {message.total_attempts} times.")
323
+ current_request = message
324
+ while True:
325
+ result = self.request_single(current_request)
326
+ if isinstance(result, RequestMessage2):
327
+ current_request = result
328
+ elif isinstance(result, HTTPResult2):
329
+ return result
330
+ else:
331
+ raise TypeError(f"Unexpected result type: {type(result)}")
332
+
333
+ def _make_request2(self, message: BaseRequestMessage) -> httpx.Response:
334
+ headers = self._create_headers(message.api_version, message.content_type, message.accept)
335
+ return self.session.request(
336
+ method=message.method,
337
+ url=message.endpoint_url,
338
+ content=message.content,
339
+ headers=headers,
340
+ params=message.parameters,
341
+ timeout=self.config.timeout,
342
+ follow_redirects=False,
343
+ )
344
+
345
+ def _handle_response_single(
346
+ self, response: httpx.Response, request: RequestMessage2
347
+ ) -> RequestMessage2 | HTTPResult2:
348
+ if 200 <= response.status_code < 300:
349
+ return SuccessResponse2(
350
+ status_code=response.status_code,
351
+ body=response.text,
352
+ content=response.content,
353
+ )
354
+ if retry_request := self._retry_request(response, request):
355
+ return retry_request
356
+ else:
357
+ # Permanent failure
358
+ return FailedResponse2(
359
+ status_code=response.status_code,
360
+ body=response.text,
361
+ error=ErrorDetails2.from_response(response),
362
+ )
363
+
364
+ def _retry_request(self, response: httpx.Response, request: _T_Request_Message) -> _T_Request_Message | None:
365
+ retry_after = self._get_retry_after_in_header(response)
366
+ if retry_after is not None and response.status_code == 429 and request.status_attempt < self._max_retries:
367
+ if self._console is not None:
368
+ short_url = request.endpoint_url.removeprefix(self.config.base_api_url)
369
+ HighSeverityWarning(
370
+ f"Rate limit exceeded for the {short_url!r} endpoint. Retrying after {retry_after} seconds."
371
+ ).print_warning(console=self._console)
372
+ request.status_attempt += 1
373
+ time.sleep(retry_after)
374
+ return request
375
+
376
+ if request.status_attempt < self._max_retries and response.status_code in self._retry_status_codes:
377
+ request.status_attempt += 1
378
+ time.sleep(self._backoff_time(request.total_attempts))
379
+ return request
380
+ return None
381
+
382
+ def _handle_error_single(self, e: Exception, request: RequestMessage2) -> RequestMessage2 | HTTPResult2:
383
+ if isinstance(e, httpx.ReadTimeout | httpx.TimeoutException):
384
+ error_type = "read"
385
+ request.read_attempt += 1
386
+ attempts = request.read_attempt
387
+ elif isinstance(e, ConnectionError | httpx.ConnectError | httpx.ConnectTimeout):
388
+ error_type = "connect"
389
+ request.connect_attempt += 1
390
+ attempts = request.connect_attempt
391
+ else:
392
+ error_msg = f"Unexpected exception: {e!s}"
393
+ return FailedRequest2(error=error_msg)
394
+
395
+ if attempts <= self._max_retries:
396
+ time.sleep(self._backoff_time(request.total_attempts))
397
+ return request
398
+ else:
399
+ # We have already incremented the attempt count, so we subtract 1 here
400
+ error_msg = f"RequestException after {request.total_attempts - 1} attempts ({error_type} error): {e!s}"
401
+
402
+ return FailedRequest2(error=error_msg)
403
+
404
+ def request_items(self, message: ItemsRequest2) -> Sequence[ItemsRequest2 | ItemsResultMessage2]:
405
+ """Send an HTTP request with multiple items and return the response.
406
+
407
+ Args:
408
+ message (ItemsRequest2): The request message to send.
409
+ Returns:
410
+ Sequence[ItemsRequest2 | ItemsResultMessage2]: The response message(s). This can also
411
+ include ItemsRequest2(s) to be retried or split.
412
+ """
413
+ if message.tracker and message.tracker.limit_reached():
414
+ return [
415
+ ItemsFailedRequest2(
416
+ ids=[item.as_id() for item in message.items],
417
+ error_message=f"Aborting further splitting of requests after {message.tracker.failed_split_count} failed attempts.",
418
+ )
419
+ ]
420
+ try:
421
+ response = self._make_request2(message)
422
+ results = self._handle_items_response(response, message)
423
+ except Exception as e:
424
+ results = self._handle_items_error(e, message)
425
+ return results
426
+
427
+ def request_items_retries(self, message: ItemsRequest2) -> Sequence[ItemsResultMessage2]:
428
+ """Send an HTTP request with multiple items and handle retries.
429
+
430
+ This method will keep retrying the request until it either succeeds or
431
+ exhausts the maximum number of retries.
432
+
433
+ Note this method will use the current thread to process all request, thus
434
+ it is blocking.
435
+
436
+ Args:
437
+ message (ItemsRequest2): The request message to send.
438
+ Returns:
439
+ Sequence[ItemsResultMessage2]: The final response message, which can be either successful response or failed request.
440
+ """
441
+ if message.total_attempts > 0:
442
+ raise RuntimeError(f"ItemsRequest2 has already been attempted {message.total_attempts} times.")
443
+ pending_requests: deque[ItemsRequest2] = deque()
444
+ pending_requests.append(message)
445
+ final_responses: list[ItemsResultMessage2] = []
446
+ while pending_requests:
447
+ current_request = pending_requests.popleft()
448
+ results = self.request_items(current_request)
449
+
450
+ for result in results:
451
+ if isinstance(result, ItemsRequest2):
452
+ pending_requests.append(result)
453
+ elif isinstance(result, ItemsResultMessage2):
454
+ final_responses.append(result)
455
+ else:
456
+ raise TypeError(f"Unexpected result type: {type(result)}")
457
+
458
+ return final_responses
459
+
460
+ def _handle_items_response(
461
+ self, response: httpx.Response, request: ItemsRequest2
462
+ ) -> Sequence[ItemsRequest2 | ItemsResultMessage2]:
463
+ if 200 <= response.status_code < 300:
464
+ return [
465
+ ItemsSuccessResponse2(
466
+ ids=[item.as_id() for item in request.items],
467
+ status_code=response.status_code,
468
+ body=response.text,
469
+ content=response.content,
470
+ )
471
+ ]
472
+ elif len(request.items) > 1 and response.status_code in self._split_items_status_codes:
473
+ # 4XX: Status there is at least one item that is invalid, split the batch to get all valid items processed
474
+ # 5xx: Server error, split to reduce the number of items in each request, and count as a status attempt
475
+ status_attempts = request.status_attempt
476
+ if 500 <= response.status_code < 600:
477
+ status_attempts += 1
478
+ splits = request.split(status_attempts=status_attempts)
479
+ if splits[0].tracker and splits[0].tracker.limit_reached():
480
+ return [
481
+ ItemsFailedResponse2(
482
+ ids=[item.as_id() for item in request.items],
483
+ status_code=response.status_code,
484
+ body=response.text,
485
+ error=ErrorDetails2.from_response(response),
486
+ )
487
+ ]
488
+ return splits
489
+
490
+ if retry_request := self._retry_request(response, request):
491
+ return [retry_request]
492
+ else:
493
+ # Permanent failure
494
+ return [
495
+ ItemsFailedResponse2(
496
+ ids=[item.as_id() for item in request.items],
497
+ status_code=response.status_code,
498
+ body=response.text,
499
+ error=ErrorDetails2.from_response(response),
500
+ )
501
+ ]
502
+
503
+ def _handle_items_error(
504
+ self, e: Exception, request: ItemsRequest2
505
+ ) -> Sequence[ItemsRequest2 | ItemsResultMessage2]:
506
+ if isinstance(e, httpx.ReadTimeout | httpx.TimeoutException):
507
+ error_type = "read"
508
+ request.read_attempt += 1
509
+ attempts = request.read_attempt
510
+ elif isinstance(e, ConnectionError | httpx.ConnectError | httpx.ConnectTimeout):
511
+ error_type = "connect"
512
+ request.connect_attempt += 1
513
+ attempts = request.connect_attempt
514
+ else:
515
+ error_msg = f"Unexpected exception: {e!s}"
516
+ return [
517
+ ItemsFailedRequest2(
518
+ ids=[item.as_id() for item in request.items],
519
+ error_message=error_msg,
520
+ )
521
+ ]
522
+
523
+ if attempts <= self._max_retries:
524
+ time.sleep(self._backoff_time(request.total_attempts))
525
+ return [request]
526
+ else:
527
+ # We have already incremented the attempt count, so we subtract 1 here
528
+ error_msg = f"RequestException after {request.total_attempts - 1} attempts ({error_type} error): {e!s}"
529
+
530
+ return [
531
+ ItemsFailedRequest2(
532
+ ids=[item.as_id() for item in request.items],
533
+ error_message=error_msg,
534
+ )
535
+ ]
@@ -1,6 +1,6 @@
1
1
  from abc import ABC, abstractmethod
2
2
  from collections import UserList
3
- from collections.abc import Sequence
3
+ from collections.abc import Hashable, Sequence
4
4
  from dataclasses import dataclass, field
5
5
  from typing import Generic, Literal, Protocol, TypeAlias, TypeVar
6
6
 
@@ -87,13 +87,14 @@ class RequestMessage(HTTPMessage):
87
87
  """Base class for HTTP request messages"""
88
88
 
89
89
  endpoint_url: str
90
- method: Literal["GET", "POST", "PATCH", "DELETE"]
90
+ method: Literal["GET", "POST", "PATCH", "DELETE", "PUT"]
91
91
  connect_attempt: int = 0
92
92
  read_attempt: int = 0
93
93
  status_attempt: int = 0
94
94
  api_version: str | None = None
95
95
  content_type: str = "application/json"
96
96
  accept: str = "application/json"
97
+ content_length: int | None = None
97
98
 
98
99
  @property
99
100
  def total_attempts(self) -> int:
@@ -115,6 +116,13 @@ class RequestMessage(HTTPMessage):
115
116
  @dataclass
116
117
  class SuccessResponse(ResponseMessage):
117
118
  body: str
119
+ content: bytes
120
+
121
+ def dump(self) -> dict[str, JsonVal]:
122
+ output = super().dump()
123
+ # We cannot serialize bytes, so we indicate its presence instead
124
+ output["content"] = "<bytes>" if self.content else None
125
+ return output
118
126
 
119
127
 
120
128
  @dataclass
@@ -134,7 +142,7 @@ class SimpleRequest(RequestMessage):
134
142
 
135
143
  @classmethod
136
144
  def create_success_response(cls, response: httpx.Response) -> Sequence[ResponseMessage]:
137
- return [SuccessResponse(status_code=response.status_code, body=response.text)]
145
+ return [SuccessResponse(status_code=response.status_code, body=response.text, content=response.content)]
138
146
 
139
147
  @classmethod
140
148
  def create_failure_response(cls, response: httpx.Response) -> Sequence[HTTPMessage]:
@@ -309,7 +317,11 @@ class ItemsRequest(Generic[T_COVARIANT_ID], BodyRequest):
309
317
 
310
318
  def create_success_response(self, response: httpx.Response) -> Sequence[HTTPMessage]:
311
319
  ids = [item.as_id() for item in self.items]
312
- return [SuccessResponseItems(status_code=response.status_code, ids=ids, body=response.text)]
320
+ return [
321
+ SuccessResponseItems(
322
+ status_code=response.status_code, ids=ids, body=response.text, content=response.content
323
+ )
324
+ ]
313
325
 
314
326
  def create_failure_response(self, response: httpx.Response) -> Sequence[HTTPMessage]:
315
327
  error = ErrorDetails.from_response(response)
@@ -338,6 +350,18 @@ class ResponseList(UserList[ResponseMessage | FailedRequestMessage]):
338
350
  error_messages += "; ".join(f"Request error: {err.error}" for err in failed_requests)
339
351
  raise ToolkitAPIError(f"One or more requests failed: {error_messages}")
340
352
 
353
+ @property
354
+ def has_failed(self) -> bool:
355
+ """Indicates whether any response in the list indicates a failure.
356
+
357
+ Returns:
358
+ bool: True if there are any failed responses or requests, False otherwise.
359
+ """
360
+ for resp in self.data:
361
+ if isinstance(resp, FailedResponse | FailedRequestMessage):
362
+ return True
363
+ return False
364
+
341
365
  def get_first_body(self) -> dict[str, JsonVal]:
342
366
  """Returns the body of the first successful response in the list.
343
367
 
@@ -352,6 +376,28 @@ class ResponseList(UserList[ResponseMessage | FailedRequestMessage]):
352
376
  return _json.loads(resp.body)
353
377
  raise ValueError("No successful responses with a body found.")
354
378
 
379
+ def as_item_responses(self, item_id: Hashable) -> list[ResponseMessage | FailedRequestMessage]:
380
+ # Convert the responses to per-item responses
381
+ results: list[ResponseMessage | FailedRequestMessage] = []
382
+ for message in self.data:
383
+ if isinstance(message, SuccessResponse):
384
+ results.append(
385
+ SuccessResponseItems(
386
+ status_code=message.status_code, content=message.content, ids=[item_id], body=message.body
387
+ )
388
+ )
389
+ elif isinstance(message, FailedResponse):
390
+ results.append(
391
+ FailedResponseItems(
392
+ status_code=message.status_code, ids=[item_id], body=message.body, error=message.error
393
+ )
394
+ )
395
+ elif isinstance(message, FailedRequestMessage):
396
+ results.append(FailedRequestItems(ids=[item_id], error=message.error))
397
+ else:
398
+ results.append(message)
399
+ return results
400
+
355
401
 
356
402
  def _dump_body(body: dict[str, JsonVal]) -> str:
357
403
  try: