python-etcd3-async 0.1.6__py3-none-any.whl → 0.2.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.
- etcd3/__init__.py +3 -1
- etcd3/async_client.py +21 -0
- etcd3/async_client_extensions.py +147 -59
- etcd3/async_endpoint.py +17 -3
- etcd3/async_managers.py +30 -5
- etcd3/async_multi_client.py +236 -107
- etcd3/async_single_client.py +18 -3
- etcd3/async_watch.py +125 -113
- etcd3/client.py +20 -0
- etcd3/endpoint.py +16 -0
- etcd3/errors.py +14 -0
- etcd3/locks.py +49 -13
- etcd3/multi_client.py +236 -47
- etcd3/shared.py +1 -55
- etcd3/sync_kv_operations.py +69 -59
- etcd3/utils.py +0 -35
- {python_etcd3_async-0.1.6.dist-info → python_etcd3_async-0.2.1.dist-info}/METADATA +29 -6
- {python_etcd3_async-0.1.6.dist-info → python_etcd3_async-0.2.1.dist-info}/RECORD +19 -19
- {python_etcd3_async-0.1.6.dist-info → python_etcd3_async-0.2.1.dist-info}/WHEEL +1 -1
etcd3/__init__.py
CHANGED
|
@@ -8,6 +8,7 @@ from etcd3.shared import Transactions
|
|
|
8
8
|
from etcd3.async_single_client import AsyncEtcd3Client
|
|
9
9
|
from etcd3.async_multi_client import AsyncMultiEndpointEtcd3Client
|
|
10
10
|
from etcd3.async_endpoint import AsyncEndpoint
|
|
11
|
+
from etcd3.async_endpoint import AsyncEndpointConfig
|
|
11
12
|
from etcd3.async_client_extensions import get_request_context, set_request_context
|
|
12
13
|
from etcd3.exceptions import Etcd3Exception
|
|
13
14
|
from etcd3.observability import ObservabilityConfig
|
|
@@ -50,7 +51,7 @@ from etcd3.async_single_client import async_client
|
|
|
50
51
|
|
|
51
52
|
__author__ = "owasd"
|
|
52
53
|
__email__ = ""
|
|
53
|
-
__version__ = "0.1
|
|
54
|
+
__version__ = "0.2.1"
|
|
54
55
|
|
|
55
56
|
__all__ = (
|
|
56
57
|
"etcdrpc",
|
|
@@ -71,6 +72,7 @@ __all__ = (
|
|
|
71
72
|
"Permission",
|
|
72
73
|
"MultiEndpointEtcd3Client",
|
|
73
74
|
"AsyncEndpoint",
|
|
75
|
+
"AsyncEndpointConfig",
|
|
74
76
|
"AsyncEtcd3Client",
|
|
75
77
|
"AsyncMultiEndpointEtcd3Client",
|
|
76
78
|
"async_client",
|
etcd3/async_client.py
CHANGED
|
@@ -8,6 +8,9 @@ For new code, prefer importing from:
|
|
|
8
8
|
- etcd3.async_endpoint: AsyncEndpoint
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
import sys
|
|
12
|
+
import types
|
|
13
|
+
|
|
11
14
|
# Re-export config classes
|
|
12
15
|
from etcd3.async_config import AsyncClientConfig, AsyncMultiEndpointConfig
|
|
13
16
|
from etcd3.config import RangeRequestConfig
|
|
@@ -48,3 +51,21 @@ __all__ = [
|
|
|
48
51
|
"_async_handle_keepalive_errors",
|
|
49
52
|
"async_client",
|
|
50
53
|
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _CallableModule(types.ModuleType):
|
|
57
|
+
"""Module that can also be awaited like the factory function it re-exports.
|
|
58
|
+
|
|
59
|
+
``import etcd3.async_client`` makes Python rebind the
|
|
60
|
+
``etcd3.async_client`` attribute from the factory function to this
|
|
61
|
+
module. Without this, the documented ``await etcd3.async_client(...)``
|
|
62
|
+
call would break for anyone who imported the compatibility shim.
|
|
63
|
+
Calling the module delegates to the factory function, so both
|
|
64
|
+
spellings keep working.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __call__(self, *args, **kwargs):
|
|
68
|
+
return async_client(*args, **kwargs)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
sys.modules[__name__].__class__ = _CallableModule
|
etcd3/async_client_extensions.py
CHANGED
|
@@ -117,7 +117,55 @@ class AsyncKVOperations:
|
|
|
117
117
|
|
|
118
118
|
def __init__(self, client, max_concurrent=100):
|
|
119
119
|
self.client = client
|
|
120
|
-
|
|
120
|
+
# Client-wide ceiling for concurrent key-value operations. A batch
|
|
121
|
+
# call may ask for less concurrency, never for more.
|
|
122
|
+
self._max_concurrent = max_concurrent
|
|
123
|
+
|
|
124
|
+
def _limit(self, concurrency):
|
|
125
|
+
"""Resolve the concurrency limit for one batch call."""
|
|
126
|
+
if concurrency is None:
|
|
127
|
+
concurrency = self._max_concurrent
|
|
128
|
+
return max(1, min(int(concurrency), self._max_concurrent))
|
|
129
|
+
|
|
130
|
+
async def _run_bounded(self, items, operation, concurrency=None):
|
|
131
|
+
"""Run ``operation`` over ``items`` with bounded concurrency.
|
|
132
|
+
|
|
133
|
+
Shared by ``batch_put``, ``batch_delete`` and ``put_many`` so that
|
|
134
|
+
all three have one concurrency policy and one failure policy:
|
|
135
|
+
per-item errors are captured and never abort the whole batch.
|
|
136
|
+
|
|
137
|
+
:param items: Items to process
|
|
138
|
+
:type items: list
|
|
139
|
+
:param operation: Async callable applied to a single item
|
|
140
|
+
:type operation: callable
|
|
141
|
+
:param concurrency: Maximum number of items processed at the same time
|
|
142
|
+
:type concurrency: int, optional
|
|
143
|
+
:returns: List of ``(item, value, error)`` tuples in input order
|
|
144
|
+
:rtype: list[tuple]
|
|
145
|
+
"""
|
|
146
|
+
limit = self._limit(concurrency)
|
|
147
|
+
# The semaphore is created per call so it always belongs to the
|
|
148
|
+
# running event loop (asyncio.Semaphore binds itself on first use).
|
|
149
|
+
semaphore = asyncio.Semaphore(limit)
|
|
150
|
+
results = [(item, None, None) for item in items]
|
|
151
|
+
|
|
152
|
+
async def run(index, item):
|
|
153
|
+
async with semaphore:
|
|
154
|
+
try:
|
|
155
|
+
value = await operation(item)
|
|
156
|
+
except Exception as exc: # noqa: BLE001 - per-item isolation
|
|
157
|
+
results[index] = (item, None, exc)
|
|
158
|
+
else:
|
|
159
|
+
results[index] = (item, value, None)
|
|
160
|
+
|
|
161
|
+
# asyncio.TaskGroup keeps every task inside this scope: no task can
|
|
162
|
+
# outlive the call unobserved, and one failing item cannot cancel
|
|
163
|
+
# the others (errors are captured inside ``run``).
|
|
164
|
+
async with asyncio.TaskGroup() as tg:
|
|
165
|
+
for index, item in enumerate(items):
|
|
166
|
+
tg.create_task(run(index, item))
|
|
167
|
+
|
|
168
|
+
return results
|
|
121
169
|
|
|
122
170
|
async def _get_kv_stub(self):
|
|
123
171
|
"""Get the KV stub using the current channel with auto-reconnection.
|
|
@@ -128,6 +176,30 @@ class AsyncKVOperations:
|
|
|
128
176
|
channel = await self.client._get_channel()
|
|
129
177
|
return etcdrpc.KVStub(channel)
|
|
130
178
|
|
|
179
|
+
async def _range(self, range_request):
|
|
180
|
+
"""Run a range request.
|
|
181
|
+
|
|
182
|
+
Reads are idempotent, so a transient failure can be retried. The
|
|
183
|
+
stub is created per attempt so that a retry picks up a freshly
|
|
184
|
+
built channel.
|
|
185
|
+
|
|
186
|
+
:param range_request: the request to send
|
|
187
|
+
:type range_request: etcdrpc.RangeRequest
|
|
188
|
+
:returns: the range response
|
|
189
|
+
:rtype: etcdrpc.RangeResponse
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
async def _do_range():
|
|
193
|
+
kv_stub = await self._get_kv_stub()
|
|
194
|
+
return await kv_stub.Range(
|
|
195
|
+
range_request,
|
|
196
|
+
timeout=self.client.timeout,
|
|
197
|
+
credentials=self.client.call_credentials,
|
|
198
|
+
metadata=self.client.metadata,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return await self.client._retry_with_backoff(_do_range)
|
|
202
|
+
|
|
131
203
|
@_async_handle_errors
|
|
132
204
|
async def get_response(self, key, **kwargs):
|
|
133
205
|
"""Get the value of a key from etcd.
|
|
@@ -138,14 +210,7 @@ class AsyncKVOperations:
|
|
|
138
210
|
:returns: RangeResponse from etcd
|
|
139
211
|
"""
|
|
140
212
|
range_request = self.client._build_get_range_request(key, **kwargs)
|
|
141
|
-
|
|
142
|
-
kv_stub = await self._get_kv_stub()
|
|
143
|
-
response = await kv_stub.Range(
|
|
144
|
-
range_request,
|
|
145
|
-
timeout=self.client.timeout,
|
|
146
|
-
credentials=self.client.call_credentials,
|
|
147
|
-
metadata=self.client.metadata,
|
|
148
|
-
)
|
|
213
|
+
response = await self._range(range_request)
|
|
149
214
|
|
|
150
215
|
# Set trace attributes from response
|
|
151
216
|
endpoint = (
|
|
@@ -239,6 +304,9 @@ class AsyncKVOperations:
|
|
|
239
304
|
:type ignore_value: bool
|
|
240
305
|
:param ignore_lease: if True, update the key using the current lease
|
|
241
306
|
:type ignore_lease: bool
|
|
307
|
+
:returns: the response header, or the previous key-value metadata
|
|
308
|
+
when ``prev_kv`` is True
|
|
309
|
+
:rtype: :class:`.rpc_pb2.ResponseHeader` or :class:`.KVMetadata`
|
|
242
310
|
"""
|
|
243
311
|
put_request = RequestBuilder.build_put_request(
|
|
244
312
|
key=key,
|
|
@@ -329,8 +397,18 @@ class AsyncKVOperations:
|
|
|
329
397
|
return delete_response.deleted
|
|
330
398
|
|
|
331
399
|
@_async_handle_errors
|
|
332
|
-
async def delete_prefix(self, prefix):
|
|
333
|
-
"""
|
|
400
|
+
async def delete_prefix(self, prefix, return_response=False):
|
|
401
|
+
"""
|
|
402
|
+
Delete a range of keys with a prefix.
|
|
403
|
+
|
|
404
|
+
:param prefix: prefix of the keys to delete
|
|
405
|
+
:type prefix: str or bytes
|
|
406
|
+
:param return_response: return the full response
|
|
407
|
+
:type return_response: bool
|
|
408
|
+
:returns: the number of deleted keys when ``return_response`` is
|
|
409
|
+
False, and the full response when ``return_response`` is True
|
|
410
|
+
:rtype: int or :class:`.rpc_pb2.DeleteRangeResponse`
|
|
411
|
+
"""
|
|
334
412
|
delete_request = RequestBuilder.build_delete_request(
|
|
335
413
|
key=prefix,
|
|
336
414
|
range_end=utils.prefix_range_end(utils.to_bytes(prefix)),
|
|
@@ -345,8 +423,24 @@ class AsyncKVOperations:
|
|
|
345
423
|
metadata=self.client.metadata,
|
|
346
424
|
)
|
|
347
425
|
|
|
426
|
+
if return_response:
|
|
427
|
+
return delete_response
|
|
348
428
|
return delete_response.deleted
|
|
349
429
|
|
|
430
|
+
async def transaction(self, compare, success=None, failure=None):
|
|
431
|
+
"""Perform a transaction.
|
|
432
|
+
|
|
433
|
+
:param compare: A list of comparisons to make
|
|
434
|
+
:param success: A list of operations to perform if all the comparisons
|
|
435
|
+
are true
|
|
436
|
+
:param failure: A list of operations to perform if any of the
|
|
437
|
+
comparisons are false
|
|
438
|
+
:return: A tuple of (operation status, responses)
|
|
439
|
+
"""
|
|
440
|
+
return await self.client.transaction(
|
|
441
|
+
compare=compare, success=success, failure=failure
|
|
442
|
+
)
|
|
443
|
+
|
|
350
444
|
async def batch_put(self, items, concurrency=10, atomic=True):
|
|
351
445
|
"""Batch put multiple key-value pairs.
|
|
352
446
|
|
|
@@ -354,8 +448,8 @@ class AsyncKVOperations:
|
|
|
354
448
|
ensuring either all operations succeed or none do. If atomic=False,
|
|
355
449
|
operations are executed in parallel with concurrency control.
|
|
356
450
|
|
|
357
|
-
:param items: Iterable of (key, value) tuples to put
|
|
358
|
-
:type items: Iterable[tuple[str | bytes, str | bytes]]
|
|
451
|
+
:param items: Iterable of (key, value) tuples or a mapping to put
|
|
452
|
+
:type items: Iterable[tuple[str | bytes, str | bytes]] or dict
|
|
359
453
|
:param concurrency: Maximum number of concurrent put operations (only used if atomic=False)
|
|
360
454
|
:type concurrency: int
|
|
361
455
|
:param atomic: If True (default), execute as atomic transaction. If False, parallel execution.
|
|
@@ -364,6 +458,9 @@ class AsyncKVOperations:
|
|
|
364
458
|
:rtype: int
|
|
365
459
|
:raises BatchOperationError: If atomic=True and some operations fail
|
|
366
460
|
"""
|
|
461
|
+
# Accept a mapping as well as an iterable of pairs
|
|
462
|
+
if isinstance(items, dict):
|
|
463
|
+
items = list(items.items())
|
|
367
464
|
# Convert to list to allow multiple iterations
|
|
368
465
|
items_list = list(items)
|
|
369
466
|
if not items_list:
|
|
@@ -397,23 +494,14 @@ class AsyncKVOperations:
|
|
|
397
494
|
) from exc
|
|
398
495
|
else:
|
|
399
496
|
# Non-atomic: parallel execution with concurrency control
|
|
400
|
-
|
|
401
|
-
|
|
497
|
+
async def put_item(item):
|
|
498
|
+
key, value = item
|
|
499
|
+
return await self.put(key, value)
|
|
402
500
|
|
|
403
|
-
|
|
404
|
-
async with semaphore:
|
|
405
|
-
try:
|
|
406
|
-
await self.put(key, value)
|
|
407
|
-
results.append((key, 1))
|
|
408
|
-
except Exception:
|
|
409
|
-
results.append((key, 0))
|
|
410
|
-
|
|
411
|
-
async with asyncio.TaskGroup() as tg:
|
|
412
|
-
for k, v in items_list:
|
|
413
|
-
tg.create_task(put_with_semaphore(k, v))
|
|
501
|
+
results = await self._run_bounded(items_list, put_item, concurrency)
|
|
414
502
|
|
|
415
503
|
# Count successful operations
|
|
416
|
-
return sum(1 for _,
|
|
504
|
+
return sum(1 for _, _, error in results if error is None)
|
|
417
505
|
|
|
418
506
|
async def batch_delete(self, keys, concurrency=10, atomic=True):
|
|
419
507
|
"""Batch delete multiple keys.
|
|
@@ -464,54 +552,54 @@ class AsyncKVOperations:
|
|
|
464
552
|
) from exc
|
|
465
553
|
else:
|
|
466
554
|
# Non-atomic: parallel execution with concurrency control
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
try:
|
|
473
|
-
result = await self.delete(key)
|
|
474
|
-
# delete returns True/False or deleted count
|
|
475
|
-
if isinstance(result, bool):
|
|
476
|
-
results.append((key, 1 if result else 0))
|
|
477
|
-
else:
|
|
478
|
-
results.append((key, 1 if result > 0 else 0))
|
|
479
|
-
except Exception:
|
|
480
|
-
results.append((key, 0))
|
|
481
|
-
|
|
482
|
-
async with asyncio.TaskGroup() as tg:
|
|
483
|
-
for k in keys_list:
|
|
484
|
-
tg.create_task(delete_with_semaphore(k))
|
|
555
|
+
async def delete_item(key):
|
|
556
|
+
# delete returns the number of deleted keys
|
|
557
|
+
return await self.delete(key)
|
|
558
|
+
|
|
559
|
+
results = await self._run_bounded(keys_list, delete_item, concurrency)
|
|
485
560
|
|
|
486
561
|
# Count successful operations
|
|
487
|
-
return sum(
|
|
562
|
+
return sum(count for _, count, error in results if error is None)
|
|
488
563
|
|
|
489
|
-
async def put_many(self, items, timeout=None):
|
|
564
|
+
async def put_many(self, items, timeout=None, concurrency=10):
|
|
490
565
|
"""Put multiple key-value pairs with controlled concurrency.
|
|
491
566
|
|
|
492
567
|
This is a simpler interface for batch puts that handles all
|
|
493
|
-
the asyncio complexity internally.
|
|
568
|
+
the asyncio complexity internally. It shares the concurrency and
|
|
569
|
+
failure policy of :meth:`batch_put`: items are processed in
|
|
570
|
+
parallel up to ``concurrency`` at a time, and a failing item never
|
|
571
|
+
aborts the rest of the batch.
|
|
494
572
|
|
|
495
573
|
:param items: Dict of {key: value} pairs or iterable of (key, value) tuples
|
|
496
574
|
:type items: dict or Iterable[tuple]
|
|
497
575
|
:param timeout: Optional timeout for each individual put operation
|
|
498
576
|
:type timeout: float
|
|
577
|
+
:param concurrency: Maximum number of concurrent put operations
|
|
578
|
+
:type concurrency: int
|
|
499
579
|
:returns: Tuple of (success_count, failed_keys)
|
|
500
580
|
:rtype: tuple[int, list]
|
|
501
581
|
"""
|
|
502
582
|
if isinstance(items, dict):
|
|
503
583
|
items = list(items.items())
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
584
|
+
items_list = list(items)
|
|
585
|
+
if not items_list:
|
|
586
|
+
return 0, []
|
|
587
|
+
|
|
588
|
+
async def put_item(item):
|
|
589
|
+
key, value = item
|
|
590
|
+
if timeout is None:
|
|
591
|
+
return await self.put(key, value)
|
|
592
|
+
# asyncio.timeout applies the deadline to the current task, so
|
|
593
|
+
# it composes with an enclosing timeout.
|
|
594
|
+
async with asyncio.timeout(timeout):
|
|
595
|
+
return await self.put(key, value)
|
|
596
|
+
|
|
597
|
+
results = await self._run_bounded(items_list, put_item, concurrency)
|
|
598
|
+
|
|
599
|
+
success_count = sum(1 for _, _, error in results if error is None)
|
|
600
|
+
failed_keys = [
|
|
601
|
+
(item[0], error) for item, _, error in results if error is not None
|
|
602
|
+
]
|
|
515
603
|
|
|
516
604
|
return success_count, failed_keys
|
|
517
605
|
|
etcd3/async_endpoint.py
CHANGED
|
@@ -41,6 +41,12 @@ class AsyncEndpointConfig:
|
|
|
41
41
|
class AsyncEndpoint(BaseEndpoint):
|
|
42
42
|
"""Represents an etcd cluster endpoint for async operations.
|
|
43
43
|
|
|
44
|
+
The signature mirrors the synchronous ``Endpoint`` class: ``host`` and
|
|
45
|
+
``port`` come first, so both ``AsyncEndpoint('localhost', 2379)`` and
|
|
46
|
+
``AsyncEndpoint(host='localhost', port=2379)`` work. An
|
|
47
|
+
``AsyncEndpointConfig`` can be passed as the keyword-only ``config``
|
|
48
|
+
argument instead.
|
|
49
|
+
|
|
44
50
|
:param config: AsyncEndpointConfig object with endpoint configuration
|
|
45
51
|
:param str host: Endpoint host (ignored if config is provided)
|
|
46
52
|
:param int port: Endpoint port (ignored if config is provided)
|
|
@@ -57,13 +63,14 @@ class AsyncEndpoint(BaseEndpoint):
|
|
|
57
63
|
|
|
58
64
|
def __init__(
|
|
59
65
|
self,
|
|
60
|
-
config=None,
|
|
61
66
|
host=None,
|
|
62
67
|
port=None,
|
|
63
68
|
secure=True,
|
|
64
69
|
creds=None,
|
|
65
70
|
time_retry=300.0,
|
|
66
71
|
opts=None,
|
|
72
|
+
*,
|
|
73
|
+
config=None,
|
|
67
74
|
):
|
|
68
75
|
# Use config object if provided, otherwise use individual parameters
|
|
69
76
|
if config is not None:
|
|
@@ -96,17 +103,24 @@ class AsyncEndpoint(BaseEndpoint):
|
|
|
96
103
|
except Exception:
|
|
97
104
|
return False
|
|
98
105
|
|
|
99
|
-
async def health_check_async(self):
|
|
106
|
+
async def health_check_async(self, timeout=2.0):
|
|
100
107
|
"""Perform an asynchronous health check using gRPC status check.
|
|
101
108
|
|
|
102
109
|
This method uses the channel's channel_ready() for a more robust
|
|
103
110
|
connectivity check.
|
|
104
111
|
|
|
112
|
+
:param timeout: Maximum time to wait for the channel to become ready
|
|
113
|
+
:type timeout: float, optional
|
|
105
114
|
:returns: True if the endpoint is healthy
|
|
106
115
|
:rtype: bool
|
|
107
116
|
"""
|
|
108
117
|
try:
|
|
109
|
-
|
|
118
|
+
# asyncio.timeout (3.11+) is used instead of asyncio.wait_for:
|
|
119
|
+
# it applies a deadline to the current task, so it composes
|
|
120
|
+
# correctly with an enclosing timeout instead of cancelling a
|
|
121
|
+
# nested task.
|
|
122
|
+
async with asyncio.timeout(timeout):
|
|
123
|
+
await self.channel.channel_ready()
|
|
110
124
|
return True
|
|
111
125
|
except asyncio.TimeoutError:
|
|
112
126
|
return False
|
etcd3/async_managers.py
CHANGED
|
@@ -39,6 +39,12 @@ class AsyncWatchManager:
|
|
|
39
39
|
)
|
|
40
40
|
return self._watcher
|
|
41
41
|
|
|
42
|
+
async def close(self):
|
|
43
|
+
"""Close the watcher and release its background task."""
|
|
44
|
+
if self._watcher is not None:
|
|
45
|
+
await self._watcher.close()
|
|
46
|
+
self._watcher = None
|
|
47
|
+
|
|
42
48
|
async def add_watch_callback(self, *args, **kwargs):
|
|
43
49
|
"""
|
|
44
50
|
Watch a key or range of keys and call a callback on every response.
|
|
@@ -90,23 +96,42 @@ class AsyncWatchManager:
|
|
|
90
96
|
Use ``cancel`` to cancel the watch request.
|
|
91
97
|
"""
|
|
92
98
|
watcher = await self._get_watcher()
|
|
93
|
-
|
|
99
|
+
|
|
100
|
+
# The watch runs on the shared watcher (one background task and one
|
|
101
|
+
# gRPC stream for the whole client), so the real watch id is known
|
|
102
|
+
# and the watch can actually be cancelled server-side.
|
|
103
|
+
response_queue = asyncio.Queue()
|
|
104
|
+
|
|
105
|
+
async def callback(response):
|
|
106
|
+
await response_queue.put(response)
|
|
107
|
+
|
|
108
|
+
watch_id = await watcher.add_callback(key, callback, **kwargs)
|
|
94
109
|
|
|
95
110
|
cancel_event = asyncio.Event()
|
|
96
111
|
|
|
97
112
|
async def cancel():
|
|
113
|
+
if cancel_event.is_set():
|
|
114
|
+
return
|
|
98
115
|
cancel_event.set()
|
|
99
|
-
|
|
116
|
+
# Wake up a pending iteration so it can finish.
|
|
117
|
+
await response_queue.put(None)
|
|
118
|
+
await watcher.cancel(watch_id)
|
|
100
119
|
|
|
101
120
|
async def event_iterator():
|
|
102
121
|
try:
|
|
103
|
-
|
|
104
|
-
|
|
122
|
+
while not cancel_event.is_set():
|
|
123
|
+
response = await response_queue.get()
|
|
124
|
+
if response is None:
|
|
105
125
|
break
|
|
106
|
-
|
|
126
|
+
if isinstance(response, Exception):
|
|
127
|
+
cancel_event.set()
|
|
128
|
+
raise response
|
|
129
|
+
yield response
|
|
107
130
|
except grpc.RpcError as exc:
|
|
108
131
|
if not cancel_event.is_set():
|
|
109
132
|
self.etcd_client._manage_grpc_errors(exc)
|
|
133
|
+
finally:
|
|
134
|
+
await cancel()
|
|
110
135
|
|
|
111
136
|
return event_iterator(), cancel
|
|
112
137
|
|