pyetp 0.0.45__py3-none-any.whl → 0.0.47__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 (35) hide show
  1. energistics/__init__.py +0 -0
  2. energistics/etp/__init__.py +0 -0
  3. energistics/etp/v12/__init__.py +0 -0
  4. energistics/etp/v12/datatypes/__init__.py +25 -0
  5. energistics/etp/v12/datatypes/data_array_types/__init__.py +27 -0
  6. energistics/etp/v12/datatypes/object/__init__.py +22 -0
  7. energistics/etp/v12/protocol/__init__.py +0 -0
  8. energistics/etp/v12/protocol/core/__init__.py +19 -0
  9. energistics/etp/v12/protocol/data_array/__init__.py +51 -0
  10. energistics/etp/v12/protocol/dataspace/__init__.py +23 -0
  11. energistics/etp/v12/protocol/discovery/__init__.py +21 -0
  12. energistics/etp/v12/protocol/store/__init__.py +27 -0
  13. energistics/etp/v12/protocol/transaction/__init__.py +27 -0
  14. pyetp/__init__.py +1 -2
  15. pyetp/_version.py +2 -2
  16. pyetp/client.py +426 -306
  17. pyetp/errors.py +39 -0
  18. pyetp/uri.py +3 -1
  19. pyetp/utils_arrays.py +1 -7
  20. pyetp/utils_xml.py +1 -6
  21. {pyetp-0.0.45.dist-info → pyetp-0.0.47.dist-info}/METADATA +8 -3
  22. pyetp-0.0.47.dist-info/RECORD +39 -0
  23. {pyetp-0.0.45.dist-info → pyetp-0.0.47.dist-info}/WHEEL +1 -1
  24. {pyetp-0.0.45.dist-info → pyetp-0.0.47.dist-info}/top_level.txt +2 -0
  25. rddms_io/__init__.py +0 -0
  26. rddms_io/client.py +1234 -0
  27. rddms_io/data_types.py +11 -0
  28. resqml_objects/epc_readers.py +3 -7
  29. resqml_objects/parsers.py +18 -5
  30. resqml_objects/serializers.py +25 -2
  31. resqml_objects/surface_helpers.py +295 -0
  32. resqml_objects/v201/generated.py +582 -19
  33. resqml_objects/v201/utils.py +38 -0
  34. pyetp-0.0.45.dist-info/RECORD +0 -21
  35. {pyetp-0.0.45.dist-info → pyetp-0.0.47.dist-info}/licenses/LICENSE.md +0 -0
pyetp/client.py CHANGED
@@ -2,12 +2,11 @@ import asyncio
2
2
  import contextlib
3
3
  import datetime
4
4
  import logging
5
- import sys
6
5
  import typing as T
7
6
  import uuid
8
7
  import warnings
9
8
  from collections import defaultdict
10
- from collections.abc import AsyncGenerator
9
+ from collections.abc import AsyncGenerator, Generator
11
10
  from types import TracebackType
12
11
 
13
12
  import numpy as np
@@ -17,177 +16,94 @@ import websockets.client
17
16
  from etpproto.connection import CommunicationProtocol, ConnectionType, ETPConnection
18
17
  from etpproto.messages import Message, MessageFlags
19
18
  from etptypes import ETPModel
20
- from etptypes.energistics.etp.v12.datatypes.any_array_type import AnyArrayType
21
- from etptypes.energistics.etp.v12.datatypes.any_logical_array_type import (
19
+ from pydantic import SecretStr
20
+ from xtgeo import RegularSurface
21
+
22
+ import resqml_objects.v201 as ro
23
+ from energistics.etp.v12.datatypes import (
24
+ AnyArrayType,
22
25
  AnyLogicalArrayType,
26
+ ArrayOfString,
27
+ DataValue,
28
+ ErrorInfo,
29
+ SupportedDataObject,
30
+ SupportedProtocol,
31
+ Uuid,
32
+ Version,
23
33
  )
24
- from etptypes.energistics.etp.v12.datatypes.array_of_string import ArrayOfString
25
- from etptypes.energistics.etp.v12.datatypes.data_array_types.data_array_identifier import (
34
+ from energistics.etp.v12.datatypes.data_array_types import (
26
35
  DataArrayIdentifier,
27
- )
28
- from etptypes.energistics.etp.v12.datatypes.data_array_types.data_array_metadata import (
29
36
  DataArrayMetadata,
30
- )
31
- from etptypes.energistics.etp.v12.datatypes.data_array_types.get_data_subarrays_type import (
32
37
  GetDataSubarraysType,
33
- )
34
- from etptypes.energistics.etp.v12.datatypes.data_array_types.put_data_arrays_type import (
35
38
  PutDataArraysType,
36
- )
37
- from etptypes.energistics.etp.v12.datatypes.data_array_types.put_data_subarrays_type import (
38
39
  PutDataSubarraysType,
39
- )
40
- from etptypes.energistics.etp.v12.datatypes.data_array_types.put_uninitialized_data_array_type import (
41
40
  PutUninitializedDataArrayType,
42
41
  )
43
- from etptypes.energistics.etp.v12.datatypes.data_value import DataValue
44
- from etptypes.energistics.etp.v12.datatypes.error_info import ErrorInfo
45
- from etptypes.energistics.etp.v12.datatypes.object.context_info import ContextInfo
46
- from etptypes.energistics.etp.v12.datatypes.object.context_scope_kind import (
42
+ from energistics.etp.v12.datatypes.object import (
43
+ ContextInfo,
47
44
  ContextScopeKind,
48
- )
49
- from etptypes.energistics.etp.v12.datatypes.object.data_object import DataObject
50
- from etptypes.energistics.etp.v12.datatypes.object.dataspace import Dataspace
51
- from etptypes.energistics.etp.v12.datatypes.object.relationship_kind import (
45
+ DataObject,
46
+ Dataspace,
52
47
  RelationshipKind,
48
+ Resource,
53
49
  )
54
- from etptypes.energistics.etp.v12.datatypes.object.resource import Resource
55
- from etptypes.energistics.etp.v12.datatypes.supported_data_object import (
56
- SupportedDataObject,
57
- )
58
- from etptypes.energistics.etp.v12.datatypes.supported_protocol import SupportedProtocol
59
- from etptypes.energistics.etp.v12.datatypes.uuid import Uuid
60
- from etptypes.energistics.etp.v12.datatypes.version import Version
61
- from etptypes.energistics.etp.v12.protocol.core.authorize import Authorize
62
- from etptypes.energistics.etp.v12.protocol.core.authorize_response import (
50
+ from energistics.etp.v12.protocol.core import (
51
+ Authorize,
63
52
  AuthorizeResponse,
64
- )
65
- from etptypes.energistics.etp.v12.protocol.core.close_session import CloseSession
66
- from etptypes.energistics.etp.v12.protocol.core.open_session import OpenSession
67
- from etptypes.energistics.etp.v12.protocol.core.protocol_exception import (
53
+ CloseSession,
54
+ OpenSession,
68
55
  ProtocolException,
56
+ RequestSession,
69
57
  )
70
- from etptypes.energistics.etp.v12.protocol.core.request_session import RequestSession
71
- from etptypes.energistics.etp.v12.protocol.data_array.get_data_array_metadata import (
58
+ from energistics.etp.v12.protocol.data_array import (
72
59
  GetDataArrayMetadata,
73
- )
74
- from etptypes.energistics.etp.v12.protocol.data_array.get_data_array_metadata_response import (
75
60
  GetDataArrayMetadataResponse,
76
- )
77
- from etptypes.energistics.etp.v12.protocol.data_array.get_data_arrays import (
78
61
  GetDataArrays,
79
- )
80
- from etptypes.energistics.etp.v12.protocol.data_array.get_data_arrays_response import (
81
62
  GetDataArraysResponse,
82
- )
83
- from etptypes.energistics.etp.v12.protocol.data_array.get_data_subarrays import (
84
63
  GetDataSubarrays,
85
- )
86
- from etptypes.energistics.etp.v12.protocol.data_array.get_data_subarrays_response import (
87
64
  GetDataSubarraysResponse,
88
- )
89
- from etptypes.energistics.etp.v12.protocol.data_array.put_data_arrays import (
90
65
  PutDataArrays,
91
- )
92
- from etptypes.energistics.etp.v12.protocol.data_array.put_data_arrays_response import (
93
66
  PutDataArraysResponse,
94
- )
95
- from etptypes.energistics.etp.v12.protocol.data_array.put_data_subarrays import (
96
67
  PutDataSubarrays,
97
- )
98
- from etptypes.energistics.etp.v12.protocol.data_array.put_data_subarrays_response import (
99
68
  PutDataSubarraysResponse,
100
- )
101
- from etptypes.energistics.etp.v12.protocol.data_array.put_uninitialized_data_arrays import (
102
69
  PutUninitializedDataArrays,
103
- )
104
- from etptypes.energistics.etp.v12.protocol.data_array.put_uninitialized_data_arrays_response import (
105
70
  PutUninitializedDataArraysResponse,
106
71
  )
107
- from etptypes.energistics.etp.v12.protocol.dataspace.delete_dataspaces import (
72
+ from energistics.etp.v12.protocol.dataspace import (
108
73
  DeleteDataspaces,
109
- )
110
- from etptypes.energistics.etp.v12.protocol.dataspace.delete_dataspaces_response import (
111
74
  DeleteDataspacesResponse,
112
- )
113
- from etptypes.energistics.etp.v12.protocol.dataspace.get_dataspaces import GetDataspaces
114
- from etptypes.energistics.etp.v12.protocol.dataspace.get_dataspaces_response import (
75
+ GetDataspaces,
115
76
  GetDataspacesResponse,
116
- )
117
- from etptypes.energistics.etp.v12.protocol.dataspace.put_dataspaces import PutDataspaces
118
- from etptypes.energistics.etp.v12.protocol.dataspace.put_dataspaces_response import (
77
+ PutDataspaces,
119
78
  PutDataspacesResponse,
120
79
  )
121
- from etptypes.energistics.etp.v12.protocol.discovery.get_resources import GetResources
122
- from etptypes.energistics.etp.v12.protocol.store.delete_data_objects import (
123
- DeleteDataObjects,
80
+ from energistics.etp.v12.protocol.discovery import (
81
+ GetResources,
82
+ GetResourcesResponse,
124
83
  )
125
- from etptypes.energistics.etp.v12.protocol.store.delete_data_objects_response import (
84
+ from energistics.etp.v12.protocol.store import (
85
+ DeleteDataObjects,
126
86
  DeleteDataObjectsResponse,
127
- )
128
- from etptypes.energistics.etp.v12.protocol.store.get_data_objects import GetDataObjects
129
- from etptypes.energistics.etp.v12.protocol.store.get_data_objects_response import (
87
+ GetDataObjects,
130
88
  GetDataObjectsResponse,
131
- )
132
- from etptypes.energistics.etp.v12.protocol.store.put_data_objects import PutDataObjects
133
- from etptypes.energistics.etp.v12.protocol.store.put_data_objects_response import (
89
+ PutDataObjects,
134
90
  PutDataObjectsResponse,
135
91
  )
136
- from etptypes.energistics.etp.v12.protocol.transaction.commit_transaction import (
92
+ from energistics.etp.v12.protocol.transaction import (
137
93
  CommitTransaction,
138
- )
139
- from etptypes.energistics.etp.v12.protocol.transaction.rollback_transaction import (
140
94
  RollbackTransaction,
141
- )
142
- from etptypes.energistics.etp.v12.protocol.transaction.start_transaction import (
143
95
  StartTransaction,
96
+ StartTransactionResponse,
144
97
  )
145
- from pydantic import SecretStr
146
- from xtgeo import RegularSurface
147
-
148
- import resqml_objects.v201 as ro
149
98
  from pyetp import utils_arrays, utils_xml
150
99
  from pyetp._version import version
151
100
  from pyetp.config import SETTINGS
101
+ from pyetp.errors import ETPTransactionFailure
152
102
  from pyetp.uri import DataObjectURI, DataspaceURI
153
103
  from resqml_objects import parse_resqml_v201_object, serialize_resqml_v201_object
154
104
 
155
105
  logger = logging.getLogger(__name__)
156
106
 
157
- try:
158
- # for py >3.11, we can raise grouped exceptions
159
- from builtins import ExceptionGroup # type: ignore
160
- except ImportError:
161
- # Python 3.10
162
- def ExceptionGroup(msg, errors):
163
- return errors[0]
164
-
165
-
166
- try:
167
- # Python >= 3.11
168
- from asyncio import timeout
169
- except ImportError:
170
- # Python 3.10
171
- from contextlib import asynccontextmanager
172
-
173
- import async_timeout
174
-
175
- @asynccontextmanager
176
- async def timeout(delay: T.Optional[float]) -> T.Any:
177
- try:
178
- async with async_timeout.timeout(delay):
179
- yield None
180
- except asyncio.CancelledError as e:
181
- raise asyncio.TimeoutError(f"Timeout ({delay}s)") from e
182
-
183
- TimeoutError = asyncio.TimeoutError
184
-
185
- try:
186
- # Python >= 3.11
187
- from typing import Self
188
- except ImportError:
189
- Self = "ETPClient"
190
-
191
107
 
192
108
  class ETPError(Exception):
193
109
  def __init__(self, message: str, code: int):
@@ -258,11 +174,11 @@ class ETPClient(ETPConnection):
258
174
  # client
259
175
  #
260
176
 
261
- async def send(self, body: ETPModel):
177
+ async def send(self, body: ETPModel) -> list[ETPModel]:
262
178
  correlation_id = await self._send(body)
263
179
  return await self._recv(correlation_id)
264
180
 
265
- async def _send(self, body: ETPModel):
181
+ async def _send(self, body: ETPModel) -> int:
266
182
  msg = Message.get_object_message(body, message_flags=MessageFlags.FINALPART)
267
183
  if msg is None:
268
184
  raise TypeError(f"{type(body)} not valid etp protocol")
@@ -281,41 +197,15 @@ class ETPClient(ETPConnection):
281
197
 
282
198
  return msg.header.message_id
283
199
 
284
- async def _recv(self, correlation_id: int) -> ETPModel:
200
+ async def _recv(self, correlation_id: int) -> list[ETPModel]:
285
201
  assert correlation_id in self._recv_events, (
286
202
  "Trying to receive a response on non-existing message"
287
203
  )
288
204
 
289
- def timeout_intervals(etp_timeout):
290
- # Local function generating progressively longer timeout intervals.
291
-
292
- # Use the timeout-interval generator from the Python websockets
293
- # library.
294
- backoff_generator = websockets.client.backoff(
295
- initial_delay=5.0, min_delay=5.0, max_delay=20.0
296
- )
297
-
298
- # Check if we should never time out.
299
- if etp_timeout is None:
300
- # This is an infinite generator, so it should never exit.
301
- yield from backoff_generator
302
- return
303
-
304
- # Generate timeout intervals until we have reached the
305
- # `etp_timeout`-threshold.
306
- csum = 0.0
307
- for d in backoff_generator:
308
- yield d
309
-
310
- csum += d
311
-
312
- if csum >= etp_timeout:
313
- break
314
-
315
205
  for ti in timeout_intervals(self.etp_timeout):
316
206
  try:
317
207
  # Wait for an event for `ti` seconds.
318
- async with timeout(ti):
208
+ async with asyncio.timeout(ti):
319
209
  await self._recv_events[correlation_id].wait()
320
210
  except TimeoutError:
321
211
  # Check if the receiver task is still running.
@@ -323,11 +213,18 @@ class ETPClient(ETPConnection):
323
213
  # Raise any errors by waiting for the task to finish.
324
214
  await self.__recvtask
325
215
 
326
- logger.error(
327
- "Receiver task terminated without errors. This should not happen"
216
+ # Check that the receiver task stopped due to a
217
+ # (successfully) closed websockets connection.
218
+ try:
219
+ await self.ws.recv()
220
+ except websockets.ConnectionClosedOK:
221
+ pass
222
+
223
+ # Terminate client with an error.
224
+ raise ReceiveWorkerExited(
225
+ "Receiver task terminated prematurely due to a closed "
226
+ "websockets connection"
328
227
  )
329
-
330
- raise ReceiveWorkerExited
331
228
  else:
332
229
  # Break out of for-loop, and start processing message.
333
230
  break
@@ -355,11 +252,7 @@ class ETPClient(ETPConnection):
355
252
  "Server responded with ETPErrors:", ETPError.from_protos(errors)
356
253
  )
357
254
 
358
- if len(bodies) > 1:
359
- logger.warning(f"Recived {len(bodies)} messages, but only expected one")
360
-
361
- # ok
362
- return bodies[0]
255
+ return bodies
363
256
 
364
257
  @staticmethod
365
258
  def _parse_error_info(bodies: list[ETPModel]) -> list[ErrorInfo]:
@@ -372,13 +265,15 @@ class ETPClient(ETPConnection):
372
265
  errors.extend(body.errors.values())
373
266
  return errors
374
267
 
375
- async def __aexit__(self, *exc_details) -> None:
376
- await self.close(reason="Client exiting")
377
-
378
- async def close(self, reason=""):
268
+ async def __aexit__(
269
+ self,
270
+ exc_type: T.Type[BaseException] | None,
271
+ exc_value: BaseException | None,
272
+ traceback: TracebackType | None,
273
+ ) -> None:
379
274
  close_session_sent = False
380
275
  try:
381
- await self._send(CloseSession(reason=reason))
276
+ await self._send(CloseSession(reason="Client exiting"))
382
277
  close_session_sent = True
383
278
  except websockets.ConnectionClosed:
384
279
  logger.error(
@@ -419,7 +314,7 @@ class ETPClient(ETPConnection):
419
314
  # In some cases the server does not drop the connection after we
420
315
  # have sent the `CloseSession`-message. We therefore add a timeout
421
316
  # to the reading of possibly lost messages.
422
- async with timeout(self.etp_timeout or 10):
317
+ async with asyncio.timeout(self.etp_timeout or 10):
423
318
  async for msg in self.ws:
424
319
  counter += 1
425
320
  except websockets.ConnectionClosed:
@@ -428,7 +323,7 @@ class ETPClient(ETPConnection):
428
323
  pass
429
324
  except TimeoutError:
430
325
  if close_session_sent:
431
- logger.error(
326
+ logger.warning(
432
327
  "Websockets connection was not closed within "
433
328
  f"{self.etp_timeout or 10} seconds after the "
434
329
  "`CloseSession`-message was sent"
@@ -442,17 +337,30 @@ class ETPClient(ETPConnection):
442
337
 
443
338
  logger.debug("Client closed")
444
339
 
340
+ async def close(self) -> None:
341
+ """Closing method that tears down the ETP-connection via the
342
+ `ETPClient.__aexit__`-method, and closes the websockets connection.
343
+ This method should _only_ be used if the user has set up a connection
344
+ via `etp_client = await connect(...)` or `etp_client = await
345
+ etp_connect(...)` and will handle the closing of the connection
346
+ manually.
347
+ """
348
+
349
+ await self.__aexit__(None, None, None)
350
+ # The websockets connection should be closed from the ETP-server once
351
+ # it has received a `CloseSession`-message. However, calling close on
352
+ # the websockets connection does not do anything if it is already
353
+ # closed.
354
+ await self.ws.close()
355
+
445
356
  async def __recv(self):
446
357
  logger.debug("Starting receiver loop")
447
358
 
448
- while True:
449
- # We use this way of receiving messages, instead of the `async
450
- # for`-pattern, in order to raise all
451
- # `websockets.exceptions.ConnectionClosed`-errors (including the
452
- # `websockets.exceptions.ConnectionClosedOK` error). In the `async
453
- # for`-case a closing code of `1000` (normal closing) just exits
454
- # the loop.
455
- msg_data = await self.ws.recv()
359
+ # Using `async for` makes the receiver task exit without errors on a
360
+ # `websockets.exceptions.ConnectionClosedOK`-exception. This ensures a
361
+ # smoother clean-up in case the main-task errors resulting in a closed
362
+ # websockets connection down the line.
363
+ async for msg_data in self.ws:
456
364
  msg = Message.decode_binary_message(
457
365
  T.cast(bytes, msg_data), ETPClient.generic_transition_table
458
366
  )
@@ -468,11 +376,9 @@ class ETPClient(ETPConnection):
468
376
  # set response on send event
469
377
  self._recv_events[msg.header.correlation_id].set()
470
378
 
471
- #
472
- # session related
473
- #
379
+ logger.info("Websockets connection closed and receiver task stopped")
474
380
 
475
- async def __aenter__(self) -> Self:
381
+ async def __aenter__(self) -> T.Self:
476
382
  return await self.request_session()
477
383
 
478
384
  async def request_session(self):
@@ -488,7 +394,7 @@ class ETPClient(ETPConnection):
488
394
 
489
395
  return "store"
490
396
 
491
- msg = await self.send(
397
+ msgs = await self.send(
492
398
  RequestSession(
493
399
  applicationName=self.application_name,
494
400
  applicationVersion=self.application_version,
@@ -512,6 +418,10 @@ class ETPClient(ETPConnection):
512
418
  ),
513
419
  )
514
420
  )
421
+
422
+ assert len(msgs) == 1
423
+ msg = msgs[0]
424
+
515
425
  assert msg and isinstance(msg, OpenSession)
516
426
 
517
427
  self.is_connected = True
@@ -525,12 +435,15 @@ class ETPClient(ETPConnection):
525
435
  async def authorize(
526
436
  self, authorization: str, supplemental_authorization: T.Mapping[str, str] = {}
527
437
  ):
528
- msg = await self.send(
438
+ msgs = await self.send(
529
439
  Authorize(
530
440
  authorization=authorization,
531
441
  supplementalAuthorization=supplemental_authorization,
532
442
  )
533
443
  )
444
+ assert len(msgs) == 1
445
+ msg = msgs[0]
446
+
534
447
  assert msg and isinstance(msg, AuthorizeResponse)
535
448
 
536
449
  return msg
@@ -562,8 +475,10 @@ class ETPClient(ETPConnection):
562
475
  raise Exception("Max one / in dataspace name")
563
476
  return DataspaceURI.from_name(ds)
564
477
 
565
- def list_objects(self, dataspace_uri: DataspaceURI | str, depth: int = 1) -> list:
566
- return self.send(
478
+ async def list_objects(
479
+ self, dataspace_uri: DataspaceURI | str, depth: int = 1
480
+ ) -> GetResourcesResponse:
481
+ responses = await self.send(
567
482
  GetResources(
568
483
  scope=ContextScopeKind.TARGETS_OR_SELF,
569
484
  context=ContextInfo(
@@ -574,6 +489,8 @@ class ETPClient(ETPConnection):
574
489
  ),
575
490
  )
576
491
  )
492
+ assert len(responses) == 1
493
+ return responses[0]
577
494
 
578
495
  #
579
496
  # dataspace
@@ -582,10 +499,17 @@ class ETPClient(ETPConnection):
582
499
  async def get_dataspaces(
583
500
  self, store_last_write_filter: int = None
584
501
  ) -> GetDataspacesResponse:
585
- return await self.send(
502
+ responses = await self.send(
586
503
  GetDataspaces(store_last_write_filter=store_last_write_filter)
587
504
  )
588
505
 
506
+ assert all(
507
+ [isinstance(response, GetDataspacesResponse) for response in responses]
508
+ ), "Expected GetDataspacesResponse"
509
+ assert len(responses) == 1
510
+
511
+ return responses[0]
512
+
589
513
  async def put_dataspaces(
590
514
  self,
591
515
  legaltags: list[str],
@@ -593,13 +517,13 @@ class ETPClient(ETPConnection):
593
517
  owners: list[str],
594
518
  viewers: list[str],
595
519
  *dataspace_uris: DataspaceURI,
596
- ):
520
+ ) -> dict[str, str]:
597
521
  _uris = list(map(DataspaceURI.from_any, dataspace_uris))
598
522
  for i in _uris:
599
523
  if i.raw_uri.count("/") > 4: # includes the 3 eml
600
524
  raise Exception("Max one / in dataspace name")
601
525
  time = self.timestamp
602
- response = await self.send(
526
+ responses = await self.send(
603
527
  PutDataspaces(
604
528
  dataspaces={
605
529
  d.raw_uri: Dataspace(
@@ -622,15 +546,19 @@ class ETPClient(ETPConnection):
622
546
  }
623
547
  )
624
548
  )
625
- assert isinstance(response, PutDataspacesResponse), (
626
- "Expected PutDataspacesResponse"
627
- )
549
+ assert all(
550
+ [isinstance(response, PutDataspacesResponse) for response in responses]
551
+ ), "Expected PutDataspacesResponse"
552
+
553
+ successes = {}
554
+ for response in responses:
555
+ successes = {**successes, **response.success}
628
556
 
629
- assert len(response.success) == len(dataspace_uris), (
630
- f"expected {len(dataspace_uris)} success's"
557
+ assert len(successes) == len(dataspace_uris), (
558
+ f"expected {len(dataspace_uris)} successes"
631
559
  )
632
560
 
633
- return response.success
561
+ return successes
634
562
 
635
563
  async def put_dataspaces_no_raise(
636
564
  self,
@@ -639,22 +567,30 @@ class ETPClient(ETPConnection):
639
567
  owners: list[str],
640
568
  viewers: list[str],
641
569
  *dataspace_uris: DataspaceURI,
642
- ):
570
+ ) -> dict[str, str]:
643
571
  try:
644
572
  return await self.put_dataspaces(
645
573
  legaltags, otherRelevantDataCountries, owners, viewers, *dataspace_uris
646
574
  )
647
575
  except ETPError:
648
- pass
576
+ return {}
649
577
 
650
- async def delete_dataspaces(self, *dataspace_uris: DataspaceURI):
578
+ async def delete_dataspaces(self, *dataspace_uris: DataspaceURI) -> dict[str, str]:
651
579
  _uris = list(map(str, dataspace_uris))
652
580
 
653
- response = await self.send(DeleteDataspaces(uris=dict(zip(_uris, _uris))))
654
- assert isinstance(response, DeleteDataspacesResponse), (
655
- "Expected DeleteDataspacesResponse"
581
+ responses = await self.send(DeleteDataspaces(uris=dict(zip(_uris, _uris))))
582
+ assert all(
583
+ [isinstance(response, DeleteDataspacesResponse) for response in responses]
584
+ ), "Expected DeleteDataspacesResponse"
585
+
586
+ successes = {}
587
+ for response in responses:
588
+ successes = {**successes, **response.success}
589
+
590
+ assert len(successes) == len(dataspace_uris), (
591
+ f"expected {len(dataspace_uris)} successes"
656
592
  )
657
- return response.success
593
+ return successes
658
594
 
659
595
  async def get_data_objects(self, *uris: T.Union[DataObjectURI, str]):
660
596
  tasks = []
@@ -662,7 +598,8 @@ class ETPClient(ETPConnection):
662
598
  task = self.send(GetDataObjects(uris={str(uri): str(uri)}))
663
599
  tasks.append(task)
664
600
 
665
- responses = await asyncio.gather(*tasks)
601
+ task_responses = await asyncio.gather(*tasks)
602
+ responses = [r for tr in task_responses for r in tr]
666
603
  assert len(responses) == len(uris)
667
604
 
668
605
  data_objects = []
@@ -695,7 +632,8 @@ class ETPClient(ETPConnection):
695
632
  )
696
633
  tasks.append(task)
697
634
 
698
- responses = await asyncio.gather(*tasks)
635
+ task_responses = await asyncio.gather(*tasks)
636
+ responses = [r for tr in task_responses for r in tr]
699
637
 
700
638
  errors = []
701
639
  for response in responses:
@@ -757,12 +695,16 @@ class ETPClient(ETPConnection):
757
695
  ):
758
696
  _uris = list(map(str, uris))
759
697
 
760
- response = await self.send(
698
+ responses = await self.send(
761
699
  DeleteDataObjects(
762
700
  uris=dict(zip(_uris, _uris)),
763
701
  prune_contained_objects=prune_contained_objects,
764
702
  )
765
703
  )
704
+
705
+ assert len(responses) == 1
706
+ response = responses[0]
707
+
766
708
  assert isinstance(response, DeleteDataObjectsResponse), (
767
709
  "Expected DeleteDataObjectsResponse"
768
710
  )
@@ -770,23 +712,34 @@ class ETPClient(ETPConnection):
770
712
  return response.deleted_uris
771
713
 
772
714
  async def start_transaction(
773
- self, dataspace_uri: DataspaceURI, read_only: bool = True
715
+ self, dataspace_uri: DataspaceURI | str, read_only: bool = True
774
716
  ) -> Uuid:
775
- trans_id = await self.send(
776
- StartTransaction(
777
- read_only=read_only, dataspace_uris=[dataspace_uri.raw_uri]
778
- )
717
+ dataspace_uri = str(DataspaceURI.from_any(dataspace_uri))
718
+ responses = await self.send(
719
+ StartTransaction(read_only=read_only, dataspace_uris=[dataspace_uri])
779
720
  )
780
- if trans_id.successful is False:
781
- raise Exception(f"Failed starting transaction {dataspace_uri.raw_uri}")
782
- # uuid.UUID(bytes=trans_id.transaction_uuid)
783
- return Uuid(trans_id.transaction_uuid)
721
+ assert all(
722
+ [isinstance(response, StartTransactionResponse) for response in responses]
723
+ ), "Expected StartTransactionResponse"
724
+
725
+ assert len(responses) == 1
726
+ response = responses[0]
727
+
728
+ if not response.successful:
729
+ raise ETPTransactionFailure(f"Failed starting transaction {dataspace_uri}")
730
+
731
+ return response.transaction_uuid
784
732
 
785
733
  async def commit_transaction(self, transaction_uuid: Uuid):
786
- r = await self.send(CommitTransaction(transaction_uuid=transaction_uuid))
787
- if r.successful is False:
788
- raise Exception(r.failure_reason)
789
- return r
734
+ responses = await self.send(
735
+ CommitTransaction(transaction_uuid=transaction_uuid)
736
+ )
737
+ assert len(responses) == 1
738
+ response = responses[0]
739
+
740
+ if response.successful is False:
741
+ raise ETPTransactionFailure(response.failure_reason)
742
+ return response
790
743
 
791
744
  async def rollback_transaction(self, transaction_id: Uuid):
792
745
  return await self.send(RollbackTransaction(transactionUuid=transaction_id))
@@ -811,20 +764,19 @@ class ETPClient(ETPConnection):
811
764
  "obj must be Grid2DRepresentation"
812
765
  )
813
766
  sgeo = gri.grid2d_patch.geometry.points.supporting_geometry # type: ignore
814
- if sys.version_info[1] != 10:
815
- assert isinstance(
816
- gri.grid2d_patch.geometry.points, ro.Point3dZValueArray
817
- ), "Points must be Point3dZValueArray"
818
- assert isinstance(
819
- gri.grid2d_patch.geometry.points.zvalues, ro.DoubleHdf5Array
820
- ), "Values must be DoubleHdf5Array"
821
- assert isinstance(
822
- gri.grid2d_patch.geometry.points.supporting_geometry,
823
- ro.Point3dLatticeArray,
824
- ), "Points support_geo must be Point3dLatticeArray"
825
- assert isinstance(sgeo, ro.Point3dLatticeArray), (
826
- "supporting_geometry must be Point3dLatticeArray"
827
- )
767
+ assert isinstance(gri.grid2d_patch.geometry.points, ro.Point3dZValueArray), (
768
+ "Points must be Point3dZValueArray"
769
+ )
770
+ assert isinstance(
771
+ gri.grid2d_patch.geometry.points.zvalues, ro.DoubleHdf5Array
772
+ ), "Values must be DoubleHdf5Array"
773
+ assert isinstance(
774
+ gri.grid2d_patch.geometry.points.supporting_geometry,
775
+ ro.Point3dLatticeArray,
776
+ ), "Points support_geo must be Point3dLatticeArray"
777
+ assert isinstance(sgeo, ro.Point3dLatticeArray), (
778
+ "supporting_geometry must be Point3dLatticeArray"
779
+ )
828
780
  assert isinstance(
829
781
  gri.grid2d_patch.geometry.points.zvalues.values, ro.Hdf5Dataset
830
782
  ), "Values must be Hdf5Dataset"
@@ -893,9 +845,12 @@ class ETPClient(ETPConnection):
893
845
  #
894
846
 
895
847
  async def get_array_metadata(self, *uids: DataArrayIdentifier):
896
- response = await self.send(
848
+ responses = await self.send(
897
849
  GetDataArrayMetadata(dataArrays={i.path_in_resource: i for i in uids})
898
850
  )
851
+ assert len(responses) == 1
852
+ response = responses[0]
853
+
899
854
  assert isinstance(response, GetDataArrayMetadataResponse)
900
855
 
901
856
  if len(response.array_metadata) != len(uids):
@@ -915,9 +870,13 @@ class ETPClient(ETPConnection):
915
870
  ):
916
871
  return await self._get_array_chunked(uid)
917
872
 
918
- response = await self.send(
873
+ responses = await self.send(
919
874
  GetDataArrays(dataArrays={uid.path_in_resource: uid})
920
875
  )
876
+
877
+ assert len(responses) == 1
878
+ response = responses[0]
879
+
921
880
  assert isinstance(response, GetDataArraysResponse), (
922
881
  "Expected GetDataArraysResponse"
923
882
  )
@@ -936,10 +895,13 @@ class ETPClient(ETPConnection):
936
895
  path_in_resource=path_in_resource,
937
896
  )
938
897
 
939
- response = await self.send(
898
+ responses = await self.send(
940
899
  GetDataArrayMetadata(data_arrays={dai.path_in_resource: dai}),
941
900
  )
942
901
 
902
+ assert len(responses) == 1
903
+ response = responses[0]
904
+
943
905
  self.assert_response(response, GetDataArrayMetadataResponse)
944
906
  assert (
945
907
  len(response.array_metadata) == 1
@@ -1002,7 +964,12 @@ class ETPClient(ETPConnection):
1002
964
  )
1003
965
  tasks.append(task)
1004
966
 
1005
- responses = await asyncio.gather(*tasks)
967
+ task_responses = await asyncio.gather(*tasks)
968
+ responses = [
969
+ response
970
+ for task_response in task_responses
971
+ for response in task_response
972
+ ]
1006
973
 
1007
974
  data_blocks = []
1008
975
  for i, response in enumerate(responses):
@@ -1037,10 +1004,13 @@ class ETPClient(ETPConnection):
1037
1004
  return data
1038
1005
 
1039
1006
  # Download the full array in one go.
1040
- response = await self.send(
1007
+ responses = await self.send(
1041
1008
  GetDataArrays(data_arrays={dai.path_in_resource: dai}),
1042
1009
  )
1043
1010
 
1011
+ assert len(responses) == 1
1012
+ response = responses[0]
1013
+
1044
1014
  self.assert_response(response, GetDataArraysResponse)
1045
1015
  assert (
1046
1016
  len(response.data_arrays) == 1
@@ -1072,7 +1042,7 @@ class ETPClient(ETPConnection):
1072
1042
  now = self.timestamp
1073
1043
 
1074
1044
  # Allocate space on server for the array.
1075
- response = await self.send(
1045
+ responses = await self.send(
1076
1046
  PutUninitializedDataArrays(
1077
1047
  data_arrays={
1078
1048
  dai.path_in_resource: PutUninitializedDataArrayType(
@@ -1089,6 +1059,9 @@ class ETPClient(ETPConnection):
1089
1059
  ),
1090
1060
  )
1091
1061
 
1062
+ assert len(responses) == 1
1063
+ response = responses[0]
1064
+
1092
1065
  self.assert_response(response, PutUninitializedDataArraysResponse)
1093
1066
  assert len(response.success) == 1 and dai.path_in_resource in response.success
1094
1067
 
@@ -1138,7 +1111,14 @@ class ETPClient(ETPConnection):
1138
1111
  tasks.append(task)
1139
1112
 
1140
1113
  # Upload all blocks.
1141
- responses = await asyncio.gather(*tasks)
1114
+ task_responses = await asyncio.gather(*tasks)
1115
+
1116
+ # Flatten list of responses.
1117
+ responses = [
1118
+ response
1119
+ for task_response in task_responses
1120
+ for response in task_response
1121
+ ]
1142
1122
 
1143
1123
  # Check for successful responses.
1144
1124
  for response in responses:
@@ -1155,7 +1135,7 @@ class ETPClient(ETPConnection):
1155
1135
  etp_array_data = utils_arrays.get_etp_data_array_from_numpy(data)
1156
1136
 
1157
1137
  # Pass entire array in one message.
1158
- response = await self.send(
1138
+ responses = await self.send(
1159
1139
  PutDataArrays(
1160
1140
  data_arrays={
1161
1141
  dai.path_in_resource: PutDataArraysType(
@@ -1166,6 +1146,9 @@ class ETPClient(ETPConnection):
1166
1146
  )
1167
1147
  )
1168
1148
 
1149
+ assert len(responses) == 1
1150
+ response = responses[0]
1151
+
1169
1152
  self.assert_response(response, PutDataArraysResponse)
1170
1153
  assert len(response.success) == 1 and dai.path_in_resource in response.success
1171
1154
 
@@ -1187,7 +1170,7 @@ class ETPClient(ETPConnection):
1187
1170
  if data.nbytes > self.max_array_size:
1188
1171
  return await self._put_array_chunked(uid, data)
1189
1172
 
1190
- response = await self.send(
1173
+ responses = await self.send(
1191
1174
  PutDataArrays(
1192
1175
  data_arrays={
1193
1176
  uid.path_in_resource: PutDataArraysType(
@@ -1198,6 +1181,9 @@ class ETPClient(ETPConnection):
1198
1181
  )
1199
1182
  )
1200
1183
 
1184
+ assert len(responses) == 1
1185
+ response = responses[0]
1186
+
1201
1187
  assert isinstance(response, PutDataArraysResponse), (
1202
1188
  "Expected PutDataArraysResponse"
1203
1189
  )
@@ -1221,9 +1207,13 @@ class ETPClient(ETPConnection):
1221
1207
  starts=starts.tolist(),
1222
1208
  counts=counts.tolist(),
1223
1209
  )
1224
- response = await self.send(
1210
+ responses = await self.send(
1225
1211
  GetDataSubarrays(dataSubarrays={uid.path_in_resource: payload})
1226
1212
  )
1213
+
1214
+ assert len(responses) == 1
1215
+ response = responses[0]
1216
+
1227
1217
  assert isinstance(response, GetDataSubarraysResponse), (
1228
1218
  "Expected GetDataSubarraysResponse"
1229
1219
  )
@@ -1262,9 +1252,13 @@ class ETPClient(ETPConnection):
1262
1252
  f"{dataarray.data.item.__class__.__name__}"
1263
1253
  )
1264
1254
 
1265
- response = await self.send(
1255
+ responses = await self.send(
1266
1256
  PutDataSubarrays(dataSubarrays={uid.path_in_resource: payload})
1267
1257
  )
1258
+
1259
+ assert len(responses) == 1
1260
+ response = responses[0]
1261
+
1268
1262
  assert isinstance(response, PutDataSubarraysResponse), (
1269
1263
  "Expected PutDataSubarraysResponse"
1270
1264
  )
@@ -1386,9 +1380,13 @@ class ETPClient(ETPConnection):
1386
1380
  )
1387
1381
  ),
1388
1382
  )
1389
- response = await self.send(
1383
+ responses = await self.send(
1390
1384
  PutUninitializedDataArrays(dataArrays={uid.path_in_resource: payload})
1391
1385
  )
1386
+
1387
+ assert len(responses) == 1
1388
+ response = responses[0]
1389
+
1392
1390
  assert isinstance(response, PutUninitializedDataArraysResponse), (
1393
1391
  "Expected PutUninitializedDataArraysResponse"
1394
1392
  )
@@ -1407,6 +1405,7 @@ class connect:
1407
1405
  # ... = await connect(...)
1408
1406
 
1409
1407
  def __await__(self):
1408
+ # The caller is response for calling `close()` on the client.
1410
1409
  return self.__aenter__().__await__()
1411
1410
 
1412
1411
  # async with connect(...) as ...:
@@ -1420,7 +1419,7 @@ class connect:
1420
1419
  if self.data_partition is not None:
1421
1420
  headers["data-partition-id"] = self.data_partition
1422
1421
 
1423
- self.ws = await websockets.connect(
1422
+ ws = await websockets.connect(
1424
1423
  self.server_url,
1425
1424
  subprotocols=[ETPClient.SUB_PROTOCOL], # type: ignore
1426
1425
  additional_headers=headers,
@@ -1430,7 +1429,7 @@ class connect:
1430
1429
  )
1431
1430
 
1432
1431
  self.client = ETPClient(
1433
- self.ws,
1432
+ ws=ws,
1434
1433
  etp_timeout=self.timeout,
1435
1434
  max_message_size=SETTINGS.MaxWebSocketMessagePayloadSize,
1436
1435
  application_name=SETTINGS.application_name,
@@ -1448,67 +1447,188 @@ class connect:
1448
1447
 
1449
1448
  # exit the async context manager
1450
1449
  async def __aexit__(self, exc_type, exc: Exception, tb: TracebackType):
1450
+ # The `ETPClient.close`-method also closes the websockets connection.
1451
1451
  await self.client.close()
1452
- await self.ws.close()
1453
1452
 
1454
1453
 
1455
- @contextlib.asynccontextmanager
1456
- async def etp_connect(
1457
- uri: str,
1458
- data_partition_id: str | None = None,
1459
- authorization: str | None = None,
1460
- etp_timeout: float = 10.0,
1461
- max_message_size: float = 2**20,
1462
- ) -> ETPClient:
1463
- additional_headers = {}
1464
-
1465
- if authorization is not None:
1466
- additional_headers["Authorization"] = authorization
1467
- if data_partition_id is not None:
1468
- additional_headers["data-partition-id"] = data_partition_id
1469
-
1470
- subprotocols = ["etp12.energistics.org"]
1471
-
1472
- async with (
1473
- websockets.connect(
1474
- uri=uri,
1475
- subprotocols=subprotocols,
1476
- max_size=max_message_size,
1477
- additional_headers=additional_headers,
1478
- ) as ws,
1479
- ETPClient(
1480
- ws=ws,
1481
- etp_timeout=etp_timeout,
1482
- max_message_size=max_message_size,
1483
- ) as etp_client,
1484
- ):
1485
- yield etp_client
1486
-
1487
-
1488
- async def etp_persistent_connect(
1489
- uri: str,
1490
- data_partition_id: str | None = None,
1491
- authorization: str | None = None,
1492
- etp_timeout: float = 10.0,
1493
- max_message_size: float = 2**20,
1494
- ) -> AsyncGenerator[ETPClient]:
1495
- additional_headers = {}
1496
-
1497
- if authorization is not None:
1498
- additional_headers["Authorization"] = authorization
1499
- if data_partition_id is not None:
1500
- additional_headers["data-partition-id"] = data_partition_id
1501
-
1502
- subprotocols = ["etp12.energistics.org"]
1503
- async for ws in websockets.connect(
1504
- uri=uri,
1505
- subprotocols=subprotocols,
1506
- max_size=max_message_size,
1507
- additional_headers=additional_headers,
1508
- ):
1509
- async with ETPClient(
1510
- ws=ws,
1511
- etp_timeout=etp_timeout,
1512
- max_message_size=max_message_size,
1513
- ) as etp_client:
1514
- yield etp_client
1454
+ class etp_connect:
1455
+ """
1456
+ Connect to an ETP server via websockets.
1457
+
1458
+ This class can act as:
1459
+
1460
+ 1. A context manager handling setup and tear-down of the connection.
1461
+ 2. An asynchronous iterator which can be used to persistently retry to
1462
+ connect if the websockets connection drops.
1463
+ 3. An awaitable connection that must be manually closed by the user.
1464
+
1465
+ See below for examples of all three cases.
1466
+
1467
+ Parameters
1468
+ ----------
1469
+ uri: str
1470
+ The uri to the ETP server. This should be the uri to a websockets
1471
+ endpoint.
1472
+ data_partition_id: str | None
1473
+ The data partition id used when connecting to the OSDU open-etp-server
1474
+ in multi-partition mode. Default is `None`.
1475
+ authorization: str | SecretStr | None
1476
+ Bearer token used for authenticating to the ETP server. This token
1477
+ should be on the form `"Bearer 1234..."`. Default is `None`.
1478
+ etp_timeout: float | None
1479
+ The timeout in seconds for when to stop waiting for a message from the
1480
+ ETP server. Setting it to `None` will persist the connection
1481
+ indefinetly. Default is `None`.
1482
+ max_message_size: float
1483
+ The maximum number of bytes for a single websockets message. Default is
1484
+ `2**20` corresponding to `1` MiB.
1485
+
1486
+
1487
+ Examples
1488
+ --------
1489
+ An example of connecting to the ETP server using :func:`etp_connect` as a
1490
+ context manager is:
1491
+
1492
+ async with etp_connect(...) as etp_client:
1493
+ ...
1494
+
1495
+ In this case the closing message and the websockets connection is closed
1496
+ once the program exits the context manager.
1497
+
1498
+
1499
+ To persist a connection if the websockets connection is dropped (for any
1500
+ reason), use :func:`etp_connect` as an asynchronous generator, viz.:
1501
+
1502
+ import websockets
1503
+
1504
+ async for etp_client in etp_connect(...):
1505
+ try:
1506
+ ...
1507
+ except websockets.ConnectionClosed:
1508
+ continue
1509
+
1510
+ # Include `break` to avoid re-running the whole block if the
1511
+ # iteration runs without any errors.
1512
+ break
1513
+
1514
+ Note that in this case the whole program under the `try`-block is re-run
1515
+ from the start if the iteration completes normally, or if the websockets
1516
+ connection is dropped. Therefore, make sure to include a `break` at the end
1517
+ of the `try`-block (as in the example above).
1518
+
1519
+
1520
+ The third option is to set up a connection via `await` and then manually
1521
+ close the connection once done:
1522
+
1523
+ etp_client = await etp_connect(...)
1524
+ ...
1525
+ await etp_client.close()
1526
+ """
1527
+
1528
+ def __init__(
1529
+ self,
1530
+ uri: str,
1531
+ data_partition_id: str | None = None,
1532
+ authorization: str | SecretStr | None = None,
1533
+ etp_timeout: float | None = None,
1534
+ max_message_size: float = 2**20,
1535
+ ) -> None:
1536
+ self.uri = uri
1537
+ self.data_partition_id = data_partition_id
1538
+
1539
+ if isinstance(authorization, SecretStr):
1540
+ self.authorization = authorization
1541
+ else:
1542
+ self.authorization = SecretStr(authorization)
1543
+
1544
+ self.etp_timeout = etp_timeout
1545
+ self.max_message_size = max_message_size
1546
+ self.subprotocols = ["etp12.energistics.org"]
1547
+
1548
+ def __await__(self) -> ETPClient:
1549
+ # The caller is responsible for calling `close()` on the client.
1550
+ return self.__aenter__().__await__()
1551
+
1552
+ def get_additional_headers(self) -> dict[str, str]:
1553
+ additional_headers = {}
1554
+
1555
+ if self.authorization.get_secret_value() is not None:
1556
+ additional_headers["Authorization"] = self.authorization.get_secret_value()
1557
+
1558
+ if self.data_partition_id is not None:
1559
+ additional_headers["data-partition-id"] = self.data_partition_id
1560
+
1561
+ return additional_headers
1562
+
1563
+ async def __aenter__(self) -> ETPClient:
1564
+ self.stack = contextlib.AsyncExitStack()
1565
+ try:
1566
+ ws = await self.stack.enter_async_context(
1567
+ websockets.connect(
1568
+ uri=self.uri,
1569
+ subprotocols=self.subprotocols,
1570
+ max_size=self.max_message_size,
1571
+ additional_headers=self.get_additional_headers(),
1572
+ )
1573
+ )
1574
+ etp_client = await self.stack.enter_async_context(
1575
+ ETPClient(
1576
+ ws=ws,
1577
+ etp_timeout=self.etp_timeout,
1578
+ max_message_size=self.max_message_size,
1579
+ )
1580
+ )
1581
+ except BaseException:
1582
+ await self.stack.aclose()
1583
+ raise
1584
+
1585
+ return etp_client
1586
+
1587
+ async def __aexit__(
1588
+ self,
1589
+ exc_type: T.Type[BaseException] | None,
1590
+ exc_value: BaseException | None,
1591
+ traceback: TracebackType | None,
1592
+ ) -> None:
1593
+ return await self.stack.aclose()
1594
+
1595
+ async def __aiter__(self) -> AsyncGenerator[ETPClient]:
1596
+ async for ws in websockets.connect(
1597
+ uri=self.uri,
1598
+ subprotocols=self.subprotocols,
1599
+ max_size=self.max_message_size,
1600
+ additional_headers=self.get_additional_headers(),
1601
+ ):
1602
+ async with ETPClient(
1603
+ ws=ws,
1604
+ etp_timeout=self.etp_timeout,
1605
+ max_message_size=self.max_message_size,
1606
+ ) as etp_client:
1607
+ yield etp_client
1608
+
1609
+
1610
+ def timeout_intervals(total_timeout: float) -> Generator[float]:
1611
+ # Local function generating progressively longer timeout intervals.
1612
+
1613
+ # Use the timeout-interval generator from the Python websockets
1614
+ # library.
1615
+ backoff_generator = websockets.client.backoff(
1616
+ initial_delay=5.0, min_delay=5.0, max_delay=20.0
1617
+ )
1618
+
1619
+ # Check if we should never time out.
1620
+ if total_timeout is None:
1621
+ # This is an infinite generator, so it should never exit.
1622
+ yield from backoff_generator
1623
+ return
1624
+
1625
+ # Generate timeout intervals until we have reached the
1626
+ # `total_timeout`-threshold.
1627
+ csum = 0.0
1628
+ for d in backoff_generator:
1629
+ yield d
1630
+
1631
+ csum += d
1632
+
1633
+ if csum >= total_timeout:
1634
+ break