python-etcd3-async 0.1.0__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 +90 -0
- etcd3/async_client.py +1495 -0
- etcd3/async_client_extensions.py +878 -0
- etcd3/async_endpoint.py +140 -0
- etcd3/async_managers.py +498 -0
- etcd3/async_monitor.py +16 -0
- etcd3/async_watch.py +462 -0
- etcd3/client.py +2021 -0
- etcd3/endpoint.py +79 -0
- etcd3/events.py +40 -0
- etcd3/exceptions.py +47 -0
- etcd3/exporters/__init__.py +18 -0
- etcd3/exporters/prometheus.py +365 -0
- etcd3/leases.py +106 -0
- etcd3/locks.py +593 -0
- etcd3/log.py +146 -0
- etcd3/log_formatters.py +228 -0
- etcd3/log_sampling.py +221 -0
- etcd3/members.py +44 -0
- etcd3/observability.py +431 -0
- etcd3/shared.py +486 -0
- etcd3/trace.py +231 -0
- etcd3/trace_attributes.py +434 -0
- etcd3/transactions.py +280 -0
- etcd3/utils.py +158 -0
- etcd3/watch.py +270 -0
- etcdrpc/__init__.py +16 -0
- etcdrpc/auth.proto +49 -0
- etcdrpc/auth_pb2.py +43 -0
- etcdrpc/auth_pb2.pyi +44 -0
- etcdrpc/auth_pb2_grpc.py +24 -0
- etcdrpc/kv.proto +43 -0
- etcdrpc/kv_pb2.py +40 -0
- etcdrpc/kv_pb2.pyi +39 -0
- etcdrpc/kv_pb2_grpc.py +24 -0
- etcdrpc/rpc.proto +924 -0
- etcdrpc/rpc_pb2.py +237 -0
- etcdrpc/rpc_pb2.pyi +811 -0
- etcdrpc/rpc_pb2_grpc.py +1893 -0
- python_etcd3_async-0.1.0.dist-info/METADATA +328 -0
- python_etcd3_async-0.1.0.dist-info/RECORD +42 -0
- python_etcd3_async-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
"""Extensions for async etcd3 client.
|
|
2
|
+
|
|
3
|
+
This module provides auxiliary classes to split the functionality of AsyncMultiEndpointEtcd3Client
|
|
4
|
+
into smaller, more manageable components.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import contextvars
|
|
9
|
+
import logging
|
|
10
|
+
import random
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import grpc
|
|
14
|
+
|
|
15
|
+
import etcdrpc
|
|
16
|
+
|
|
17
|
+
from etcd3 import exceptions
|
|
18
|
+
from etcd3 import transactions
|
|
19
|
+
from etcd3 import utils
|
|
20
|
+
from etcd3.client import KVMetadata
|
|
21
|
+
from etcd3.leases import AsyncLease
|
|
22
|
+
from etcd3.locks import AsyncLock
|
|
23
|
+
from etcd3.shared import (
|
|
24
|
+
_make_async_handle_errors_with_tracing,
|
|
25
|
+
_make_async_handle_generator_errors_with_tracing,
|
|
26
|
+
RequestBuilder,
|
|
27
|
+
)
|
|
28
|
+
from etcd3.trace import get_current_span, is_tracing_available
|
|
29
|
+
from etcd3.trace_attributes import (
|
|
30
|
+
build_response_attributes,
|
|
31
|
+
build_lease_attributes,
|
|
32
|
+
TraceAttributes,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Context variables for request tracking
|
|
39
|
+
_request_context: contextvars.ContextVar[dict[str, Any]] = contextvars.ContextVar(
|
|
40
|
+
"request_context", default=None
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_request_context() -> dict[str, Any] | None:
|
|
45
|
+
"""Get the current request context.
|
|
46
|
+
|
|
47
|
+
:returns: The current request context dictionary, or None if not set
|
|
48
|
+
"""
|
|
49
|
+
return _request_context.get()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def set_request_context(**kwargs) -> contextvars.Token:
|
|
53
|
+
"""Set values in the request context.
|
|
54
|
+
|
|
55
|
+
:param kwargs: Key-value pairs to set in the context
|
|
56
|
+
:returns: A token that can be used to reset the context
|
|
57
|
+
"""
|
|
58
|
+
current = _request_context.get() or {}
|
|
59
|
+
new_context = {**current, **kwargs}
|
|
60
|
+
return _request_context.set(new_context)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _manage_client_errors(self, exc):
|
|
64
|
+
"""Error handler for extensions that routes to client's error handler."""
|
|
65
|
+
self.client._manage_grpc_errors(exc)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _set_span_attributes_from_response(response, endpoint: str = None):
|
|
69
|
+
"""Set span attributes from etcd response.
|
|
70
|
+
|
|
71
|
+
:param response: etcd response object
|
|
72
|
+
:param endpoint: Optional endpoint string
|
|
73
|
+
"""
|
|
74
|
+
if not is_tracing_available():
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
span = get_current_span()
|
|
78
|
+
if span is None:
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
# Build attributes from response
|
|
82
|
+
attrs = build_response_attributes(header=getattr(response, 'header', None))
|
|
83
|
+
|
|
84
|
+
# Add count if available
|
|
85
|
+
if hasattr(response, 'count'):
|
|
86
|
+
attrs[TraceAttributes.ETCD_COUNT] = response.count
|
|
87
|
+
|
|
88
|
+
# Add deleted if available
|
|
89
|
+
if hasattr(response, 'deleted'):
|
|
90
|
+
attrs[TraceAttributes.ETCD_DELETED] = response.deleted
|
|
91
|
+
|
|
92
|
+
# Add endpoint if provided
|
|
93
|
+
if endpoint:
|
|
94
|
+
attrs[TraceAttributes.ETCD_ENDPOINT] = endpoint
|
|
95
|
+
|
|
96
|
+
# Set attributes on span
|
|
97
|
+
for key, value in attrs.items():
|
|
98
|
+
if value is not None:
|
|
99
|
+
span.set_attribute(key, value)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Create trace-aware error handlers
|
|
103
|
+
_async_handle_errors = _make_async_handle_errors_with_tracing(
|
|
104
|
+
_manage_client_errors, "etcd3.operation"
|
|
105
|
+
)
|
|
106
|
+
_async_handle_generator_errors = _make_async_handle_generator_errors_with_tracing(
|
|
107
|
+
_manage_client_errors, "etcd3.operation"
|
|
108
|
+
)
|
|
109
|
+
# Error handler for lease keepalive that propagates errors to allow detection of connection loss
|
|
110
|
+
_async_handle_keepalive_errors = _make_async_handle_generator_errors_with_tracing(
|
|
111
|
+
_manage_client_errors, "etcd3.keepalive", propagate_errors=True
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AsyncKVOperations:
|
|
116
|
+
"""Handles key-value operations for AsyncMultiEndpointEtcd3Client."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, client, max_concurrent=100):
|
|
119
|
+
self.client = client
|
|
120
|
+
self._semaphore = asyncio.Semaphore(max_concurrent)
|
|
121
|
+
self._batch_semaphore = asyncio.Semaphore(10) # Limit concurrent batches
|
|
122
|
+
|
|
123
|
+
async def _get_kv_stub(self):
|
|
124
|
+
"""Get the KV stub using the current channel with auto-reconnection.
|
|
125
|
+
|
|
126
|
+
Creates a new stub each time to ensure it uses the latest channel,
|
|
127
|
+
which supports automatic reconnection after etcd server restart.
|
|
128
|
+
"""
|
|
129
|
+
channel = await self.client._get_channel()
|
|
130
|
+
return etcdrpc.KVStub(channel)
|
|
131
|
+
|
|
132
|
+
@_async_handle_errors
|
|
133
|
+
async def get_response(self, key, **kwargs):
|
|
134
|
+
"""Get the value of a key from etcd.
|
|
135
|
+
|
|
136
|
+
:param key: key in etcd to get
|
|
137
|
+
:type key: str or bytes
|
|
138
|
+
:param kwargs: Additional arguments (revision, limit, sort_order, etc.)
|
|
139
|
+
:returns: RangeResponse from etcd
|
|
140
|
+
"""
|
|
141
|
+
range_request = self.client._build_get_range_request(key, **kwargs)
|
|
142
|
+
|
|
143
|
+
kv_stub = await self._get_kv_stub()
|
|
144
|
+
response = await kv_stub.Range(
|
|
145
|
+
range_request,
|
|
146
|
+
timeout=self.client.timeout,
|
|
147
|
+
credentials=self.client.call_credentials,
|
|
148
|
+
metadata=self.client.metadata,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Set trace attributes from response
|
|
152
|
+
endpoint = str(self.client.endpoint_in_use) if self.client.endpoint_in_use else None
|
|
153
|
+
_set_span_attributes_from_response(response, endpoint)
|
|
154
|
+
|
|
155
|
+
return response
|
|
156
|
+
|
|
157
|
+
async def get(self, key, **kwargs):
|
|
158
|
+
"""Get the value of a key from etcd."""
|
|
159
|
+
range_response = await self.get_response(key, **kwargs)
|
|
160
|
+
if range_response.count < 1:
|
|
161
|
+
return None, None
|
|
162
|
+
kv = range_response.kvs[0]
|
|
163
|
+
return kv.value, KVMetadata(kv, range_response.header)
|
|
164
|
+
|
|
165
|
+
async def get_prefix_response(self, key_prefix, **kwargs):
|
|
166
|
+
"""Get a range of keys with a prefix."""
|
|
167
|
+
if any(kwarg in kwargs for kwarg in ("key", "range_end")):
|
|
168
|
+
raise TypeError("Don't use key or range_end with prefix")
|
|
169
|
+
kwargs["range_end"] = utils.prefix_range_end(utils.to_bytes(key_prefix))
|
|
170
|
+
return await self.get_response(key_prefix, **kwargs)
|
|
171
|
+
|
|
172
|
+
@_async_handle_generator_errors
|
|
173
|
+
async def get_prefix(self, key_prefix, **kwargs):
|
|
174
|
+
"""Get a range of keys with a prefix."""
|
|
175
|
+
range_response = await self.get_prefix_response(key_prefix, **kwargs)
|
|
176
|
+
for kv in range_response.kvs:
|
|
177
|
+
yield kv.value, KVMetadata(kv, range_response.header)
|
|
178
|
+
|
|
179
|
+
async def get_range_response(
|
|
180
|
+
self, range_start, range_end, sort_order=None, sort_target="key", **kwargs
|
|
181
|
+
):
|
|
182
|
+
"""Get a range of keys."""
|
|
183
|
+
if any(kwarg in kwargs for kwarg in ("key", "range_end")):
|
|
184
|
+
raise TypeError("Don't use key or range_end with get_range")
|
|
185
|
+
kwargs["range_end"] = range_end
|
|
186
|
+
if sort_order is not None:
|
|
187
|
+
kwargs["sort_order"] = sort_order
|
|
188
|
+
kwargs["sort_target"] = sort_target
|
|
189
|
+
return await self.get_response(range_start, **kwargs)
|
|
190
|
+
|
|
191
|
+
@_async_handle_generator_errors
|
|
192
|
+
async def get_range(self, range_start, range_end, **kwargs):
|
|
193
|
+
"""Get a range of keys."""
|
|
194
|
+
range_response = await self.get_range_response(range_start, range_end, **kwargs)
|
|
195
|
+
for kv in range_response.kvs:
|
|
196
|
+
yield kv.value, KVMetadata(kv, range_response.header)
|
|
197
|
+
|
|
198
|
+
async def get_all_response(
|
|
199
|
+
self, sort_order=None, sort_target="key", keys_only=False
|
|
200
|
+
):
|
|
201
|
+
"""Get all keys currently stored in etcd."""
|
|
202
|
+
kwargs = {}
|
|
203
|
+
if sort_order is not None:
|
|
204
|
+
kwargs["sort_order"] = sort_order
|
|
205
|
+
kwargs["sort_target"] = sort_target
|
|
206
|
+
if keys_only:
|
|
207
|
+
kwargs["keys_only"] = True
|
|
208
|
+
# Use b'\x00' as the minimum key and b'\x00' as range_end to get all keys
|
|
209
|
+
# This works because etcd stores keys in sorted order by key bytes
|
|
210
|
+
# b'\x00' is the smallest non-empty key, and range_end=b'\x00' means
|
|
211
|
+
# include keys >= b'\x00' up to (but not including) b'\x00'
|
|
212
|
+
# Actually, we need range_end > key. Using high bytes works.
|
|
213
|
+
return await self.get_response(b"\x00", range_end=b"\xff" * 16, **kwargs)
|
|
214
|
+
|
|
215
|
+
@_async_handle_generator_errors
|
|
216
|
+
async def get_all(self, **kwargs):
|
|
217
|
+
"""Get all keys currently stored in etcd."""
|
|
218
|
+
range_response = await self.get_all_response(**kwargs)
|
|
219
|
+
for kv in range_response.kvs:
|
|
220
|
+
yield kv.value, KVMetadata(kv, range_response.header)
|
|
221
|
+
|
|
222
|
+
@_async_handle_errors
|
|
223
|
+
async def put(
|
|
224
|
+
self,
|
|
225
|
+
key,
|
|
226
|
+
value=None,
|
|
227
|
+
lease=None,
|
|
228
|
+
prev_kv=False,
|
|
229
|
+
ignore_value=False,
|
|
230
|
+
ignore_lease=False,
|
|
231
|
+
):
|
|
232
|
+
"""Save a value to etcd.
|
|
233
|
+
|
|
234
|
+
:param key: key in etcd to set
|
|
235
|
+
:param value: value to set key to (optional if ignore_value=True)
|
|
236
|
+
:type value: bytes
|
|
237
|
+
:param lease: Lease to associate with this key.
|
|
238
|
+
:type lease: either :class:`.AsyncLease`, or int (ID of lease)
|
|
239
|
+
:param prev_kv: return the previous key-value pair
|
|
240
|
+
:type prev_kv: bool
|
|
241
|
+
:param ignore_value: if True, update the key using the current value
|
|
242
|
+
:type ignore_value: bool
|
|
243
|
+
:param ignore_lease: if True, update the key using the current lease
|
|
244
|
+
:type ignore_lease: bool
|
|
245
|
+
"""
|
|
246
|
+
put_request = RequestBuilder.build_put_request(
|
|
247
|
+
key=key,
|
|
248
|
+
value=value,
|
|
249
|
+
lease=lease,
|
|
250
|
+
prev_kv=prev_kv,
|
|
251
|
+
ignore_value=ignore_value,
|
|
252
|
+
ignore_lease=ignore_lease,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
kv_stub = await self._get_kv_stub()
|
|
256
|
+
put_response = await kv_stub.Put(
|
|
257
|
+
put_request,
|
|
258
|
+
timeout=self.client.timeout,
|
|
259
|
+
credentials=self.client.call_credentials,
|
|
260
|
+
metadata=self.client.metadata,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Set trace attributes from response
|
|
264
|
+
endpoint = str(self.client.endpoint_in_use) if self.client.endpoint_in_use else None
|
|
265
|
+
_set_span_attributes_from_response(put_response, endpoint)
|
|
266
|
+
|
|
267
|
+
if prev_kv:
|
|
268
|
+
return KVMetadata(put_response.prev_kv, put_response.header)
|
|
269
|
+
return put_response.header
|
|
270
|
+
|
|
271
|
+
@_async_handle_errors
|
|
272
|
+
async def put_if_not_exists(self, key, value, lease=None):
|
|
273
|
+
"""Save a value to etcd only if the key doesn't exist."""
|
|
274
|
+
status, _ = await self.client.transaction(
|
|
275
|
+
compare=[transactions.Create(key) == 0],
|
|
276
|
+
success=[transactions.Put(key, value, lease=lease)],
|
|
277
|
+
failure=[],
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return status
|
|
281
|
+
|
|
282
|
+
@_async_handle_errors
|
|
283
|
+
async def replace(self, key, initial_value, new_value):
|
|
284
|
+
"""Replace the value of a key in etcd."""
|
|
285
|
+
status, _ = await self.client.transaction(
|
|
286
|
+
compare=[transactions.Value(key) == initial_value],
|
|
287
|
+
success=[transactions.Put(key, new_value)],
|
|
288
|
+
failure=[],
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return status
|
|
292
|
+
|
|
293
|
+
@_async_handle_errors
|
|
294
|
+
async def delete(self, key, prev_kv=False, return_response=False, range_end=None):
|
|
295
|
+
"""Delete a key or range of keys from etcd.
|
|
296
|
+
|
|
297
|
+
:param key: key in etcd to delete
|
|
298
|
+
:type key: str or bytes
|
|
299
|
+
:param prev_kv: return the deleted key-value pair
|
|
300
|
+
:type prev_kv: bool
|
|
301
|
+
:param return_response: return the full response
|
|
302
|
+
:type return_response: bool
|
|
303
|
+
:param range_end: End of the key range (for range deletes)
|
|
304
|
+
:type range_end: str or bytes, optional
|
|
305
|
+
:returns: Number of deleted keys or full response
|
|
306
|
+
"""
|
|
307
|
+
delete_request = RequestBuilder.build_delete_request(
|
|
308
|
+
key=key,
|
|
309
|
+
prev_kv=prev_kv,
|
|
310
|
+
range_end=range_end,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
kv_stub = await self._get_kv_stub()
|
|
314
|
+
delete_response = await kv_stub.DeleteRange(
|
|
315
|
+
delete_request,
|
|
316
|
+
timeout=self.client.timeout,
|
|
317
|
+
credentials=self.client.call_credentials,
|
|
318
|
+
metadata=self.client.metadata,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Set trace attributes from response
|
|
322
|
+
endpoint = str(self.client.endpoint_in_use) if self.client.endpoint_in_use else None
|
|
323
|
+
_set_span_attributes_from_response(delete_response, endpoint)
|
|
324
|
+
|
|
325
|
+
if return_response:
|
|
326
|
+
return delete_response
|
|
327
|
+
|
|
328
|
+
return delete_response.deleted
|
|
329
|
+
|
|
330
|
+
@_async_handle_errors
|
|
331
|
+
async def delete_prefix(self, prefix):
|
|
332
|
+
"""Delete a range of keys with a prefix."""
|
|
333
|
+
delete_request = RequestBuilder.build_delete_request(
|
|
334
|
+
key=prefix,
|
|
335
|
+
range_end=utils.prefix_range_end(utils.to_bytes(prefix)),
|
|
336
|
+
prev_kv=False,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
kv_stub = await self._get_kv_stub()
|
|
340
|
+
delete_response = await kv_stub.DeleteRange(
|
|
341
|
+
delete_request,
|
|
342
|
+
timeout=self.client.timeout,
|
|
343
|
+
credentials=self.client.call_credentials,
|
|
344
|
+
metadata=self.client.metadata,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
return delete_response.deleted
|
|
348
|
+
|
|
349
|
+
async def batch_put(self, items, concurrency=10, atomic=True):
|
|
350
|
+
"""Batch put multiple key-value pairs.
|
|
351
|
+
|
|
352
|
+
By default, this method executes all puts as a single atomic transaction,
|
|
353
|
+
ensuring either all operations succeed or none do. If atomic=False,
|
|
354
|
+
operations are executed in parallel with concurrency control.
|
|
355
|
+
|
|
356
|
+
:param items: Iterable of (key, value) tuples to put
|
|
357
|
+
:type items: Iterable[tuple[str | bytes, str | bytes]]
|
|
358
|
+
:param concurrency: Maximum number of concurrent put operations (only used if atomic=False)
|
|
359
|
+
:type concurrency: int
|
|
360
|
+
:param atomic: If True (default), execute as atomic transaction. If False, parallel execution.
|
|
361
|
+
:type atomic: bool
|
|
362
|
+
:returns: Number of successful operations (atomic=True: either all or none)
|
|
363
|
+
:rtype: int
|
|
364
|
+
:raises BatchOperationError: If atomic=True and some operations fail
|
|
365
|
+
"""
|
|
366
|
+
# Convert to list to allow multiple iterations
|
|
367
|
+
items_list = list(items)
|
|
368
|
+
if not items_list:
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
if atomic:
|
|
372
|
+
# Use atomic transaction for all operations
|
|
373
|
+
# This ensures either all succeed or none do
|
|
374
|
+
success_ops = [transactions.Put(key, value) for key, value in items_list]
|
|
375
|
+
# Use a dummy compare that always succeeds (create_revision > -1 is always true)
|
|
376
|
+
compare = [transactions.Create(key) > -1 for key, _ in items_list]
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
success, _ = await self.client.transaction(
|
|
380
|
+
compare=compare,
|
|
381
|
+
success=success_ops,
|
|
382
|
+
failure=[],
|
|
383
|
+
)
|
|
384
|
+
if success:
|
|
385
|
+
return len(items_list)
|
|
386
|
+
else:
|
|
387
|
+
# Transaction failed - this shouldn't happen with our compare
|
|
388
|
+
raise exceptions.BatchOperationError(
|
|
389
|
+
f"Atomic batch put failed for {len(items_list)} items"
|
|
390
|
+
)
|
|
391
|
+
except Exception as exc:
|
|
392
|
+
if isinstance(exc, exceptions.BatchOperationError):
|
|
393
|
+
raise
|
|
394
|
+
raise exceptions.BatchOperationError(f"Batch put failed: {exc}") from exc
|
|
395
|
+
else:
|
|
396
|
+
# Non-atomic: parallel execution with concurrency control
|
|
397
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
398
|
+
results = []
|
|
399
|
+
|
|
400
|
+
async def put_with_semaphore(key, value):
|
|
401
|
+
async with semaphore:
|
|
402
|
+
try:
|
|
403
|
+
await self.put(key, value)
|
|
404
|
+
results.append((key, 1))
|
|
405
|
+
except Exception:
|
|
406
|
+
results.append((key, 0))
|
|
407
|
+
|
|
408
|
+
async with asyncio.TaskGroup() as tg:
|
|
409
|
+
for k, v in items_list:
|
|
410
|
+
tg.create_task(put_with_semaphore(k, v))
|
|
411
|
+
|
|
412
|
+
# Count successful operations
|
|
413
|
+
return sum(1 for _, success in results if success == 1)
|
|
414
|
+
|
|
415
|
+
async def batch_delete(self, keys, concurrency=10, atomic=True):
|
|
416
|
+
"""Batch delete multiple keys.
|
|
417
|
+
|
|
418
|
+
By default, this method executes all deletes as a single atomic transaction,
|
|
419
|
+
ensuring either all operations succeed or none do. If atomic=False,
|
|
420
|
+
operations are executed in parallel with concurrency control.
|
|
421
|
+
|
|
422
|
+
:param keys: Iterable of keys to delete
|
|
423
|
+
:type keys: Iterable[str | bytes]
|
|
424
|
+
:param concurrency: Maximum number of concurrent delete operations (only used if atomic=False)
|
|
425
|
+
:type concurrency: int
|
|
426
|
+
:param atomic: If True (default), execute as atomic transaction. If False, parallel execution.
|
|
427
|
+
:type atomic: bool
|
|
428
|
+
:returns: Number of successful operations (atomic=True: either all or none)
|
|
429
|
+
:rtype: int
|
|
430
|
+
:raises BatchOperationError: If atomic=True and some operations fail
|
|
431
|
+
"""
|
|
432
|
+
# Convert to list to allow multiple iterations
|
|
433
|
+
keys_list = list(keys)
|
|
434
|
+
if not keys_list:
|
|
435
|
+
return 0
|
|
436
|
+
|
|
437
|
+
if atomic:
|
|
438
|
+
# Use atomic transaction for all operations
|
|
439
|
+
# This ensures either all succeed or none do
|
|
440
|
+
delete_ops = [transactions.Delete(key) for key in keys_list]
|
|
441
|
+
# Use a dummy compare that always succeeds (create_revision > -1 is always true)
|
|
442
|
+
compare = [transactions.Create(key) > -1 for key in keys_list]
|
|
443
|
+
|
|
444
|
+
try:
|
|
445
|
+
success, _ = await self.client.transaction(
|
|
446
|
+
compare=compare,
|
|
447
|
+
success=delete_ops,
|
|
448
|
+
failure=[],
|
|
449
|
+
)
|
|
450
|
+
if success:
|
|
451
|
+
return len(keys_list)
|
|
452
|
+
else:
|
|
453
|
+
raise exceptions.BatchOperationError(
|
|
454
|
+
f"Atomic batch delete failed for {len(keys_list)} keys"
|
|
455
|
+
)
|
|
456
|
+
except Exception as exc:
|
|
457
|
+
if isinstance(exc, exceptions.BatchOperationError):
|
|
458
|
+
raise
|
|
459
|
+
raise exceptions.BatchOperationError(f"Batch delete failed: {exc}") from exc
|
|
460
|
+
else:
|
|
461
|
+
# Non-atomic: parallel execution with concurrency control
|
|
462
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
463
|
+
results = []
|
|
464
|
+
|
|
465
|
+
async def delete_with_semaphore(key):
|
|
466
|
+
async with semaphore:
|
|
467
|
+
try:
|
|
468
|
+
result = await self.delete(key)
|
|
469
|
+
# delete returns True/False or deleted count
|
|
470
|
+
if isinstance(result, bool):
|
|
471
|
+
results.append((key, 1 if result else 0))
|
|
472
|
+
else:
|
|
473
|
+
results.append((key, 1 if result > 0 else 0))
|
|
474
|
+
except Exception:
|
|
475
|
+
results.append((key, 0))
|
|
476
|
+
|
|
477
|
+
async with asyncio.TaskGroup() as tg:
|
|
478
|
+
for k in keys_list:
|
|
479
|
+
tg.create_task(delete_with_semaphore(k))
|
|
480
|
+
|
|
481
|
+
# Count successful operations
|
|
482
|
+
return sum(success for _, success in results)
|
|
483
|
+
|
|
484
|
+
async def put_many(self, items, timeout=None):
|
|
485
|
+
"""Put multiple key-value pairs with controlled concurrency.
|
|
486
|
+
|
|
487
|
+
This is a simpler interface for batch puts that handles all
|
|
488
|
+
the asyncio complexity internally.
|
|
489
|
+
|
|
490
|
+
:param items: Dict of {key: value} pairs or iterable of (key, value) tuples
|
|
491
|
+
:type items: dict or Iterable[tuple]
|
|
492
|
+
:param timeout: Optional timeout for each individual put operation
|
|
493
|
+
:type timeout: float
|
|
494
|
+
:returns: Tuple of (success_count, failed_keys)
|
|
495
|
+
:rtype: tuple[int, list]
|
|
496
|
+
"""
|
|
497
|
+
if isinstance(items, dict):
|
|
498
|
+
items = list(items.items())
|
|
499
|
+
|
|
500
|
+
success_count = 0
|
|
501
|
+
failed_keys = []
|
|
502
|
+
|
|
503
|
+
for key, value in items:
|
|
504
|
+
try:
|
|
505
|
+
async with self._semaphore:
|
|
506
|
+
await asyncio.wait_for(self.put(key, value), timeout=timeout)
|
|
507
|
+
success_count += 1
|
|
508
|
+
except Exception as exc:
|
|
509
|
+
failed_keys.append((key, exc))
|
|
510
|
+
|
|
511
|
+
return success_count, failed_keys
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class AsyncLeaseOperations:
|
|
515
|
+
"""Handles lease operations for AsyncMultiEndpointEtcd3Client."""
|
|
516
|
+
|
|
517
|
+
def __init__(self, client):
|
|
518
|
+
self.client = client
|
|
519
|
+
|
|
520
|
+
async def _get_lease_stub(self):
|
|
521
|
+
"""Get the lease stub using the current channel with auto-reconnection.
|
|
522
|
+
|
|
523
|
+
Creates a new stub each time to ensure it uses the latest channel,
|
|
524
|
+
which supports automatic reconnection after etcd server restart.
|
|
525
|
+
"""
|
|
526
|
+
channel = await self.client._get_channel()
|
|
527
|
+
return etcdrpc.LeaseStub(channel)
|
|
528
|
+
|
|
529
|
+
@_async_handle_errors
|
|
530
|
+
async def lease(self, ttl, lease_id=None):
|
|
531
|
+
"""Create a new lease."""
|
|
532
|
+
lease_grant_request = etcdrpc.LeaseGrantRequest(TTL=ttl, ID=lease_id)
|
|
533
|
+
lease_stub = await self._get_lease_stub()
|
|
534
|
+
lease_grant_response = await lease_stub.LeaseGrant(
|
|
535
|
+
lease_grant_request,
|
|
536
|
+
timeout=self.client.timeout,
|
|
537
|
+
credentials=self.client.call_credentials,
|
|
538
|
+
metadata=self.client.metadata,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
# Set trace attributes for lease
|
|
542
|
+
if is_tracing_available():
|
|
543
|
+
span = get_current_span()
|
|
544
|
+
if span:
|
|
545
|
+
attrs = build_lease_attributes(
|
|
546
|
+
lease_id=lease_grant_response.ID,
|
|
547
|
+
ttl=lease_grant_response.TTL
|
|
548
|
+
)
|
|
549
|
+
endpoint = str(self.client.endpoint_in_use) if self.client.endpoint_in_use else None
|
|
550
|
+
if endpoint:
|
|
551
|
+
attrs[TraceAttributes.ETCD_ENDPOINT] = endpoint
|
|
552
|
+
for key, value in attrs.items():
|
|
553
|
+
span.set_attribute(key, value)
|
|
554
|
+
|
|
555
|
+
return AsyncLease(
|
|
556
|
+
lease_id=lease_grant_response.ID,
|
|
557
|
+
ttl=lease_grant_response.TTL,
|
|
558
|
+
etcd_client=self.client,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
@_async_handle_errors
|
|
562
|
+
async def revoke_lease(self, lease_id):
|
|
563
|
+
"""Revoke a lease."""
|
|
564
|
+
lease_revoke_request = etcdrpc.LeaseRevokeRequest(ID=lease_id)
|
|
565
|
+
lease_stub = await self._get_lease_stub()
|
|
566
|
+
await lease_stub.LeaseRevoke(
|
|
567
|
+
lease_revoke_request,
|
|
568
|
+
timeout=self.client.timeout,
|
|
569
|
+
credentials=self.client.call_credentials,
|
|
570
|
+
metadata=self.client.metadata,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# Set trace attributes for lease revocation
|
|
574
|
+
if is_tracing_available():
|
|
575
|
+
span = get_current_span()
|
|
576
|
+
if span:
|
|
577
|
+
attrs = build_lease_attributes(lease_id=lease_id)
|
|
578
|
+
endpoint = str(self.client.endpoint_in_use) if self.client.endpoint_in_use else None
|
|
579
|
+
if endpoint:
|
|
580
|
+
attrs[TraceAttributes.ETCD_ENDPOINT] = endpoint
|
|
581
|
+
for key, value in attrs.items():
|
|
582
|
+
span.set_attribute(key, value)
|
|
583
|
+
|
|
584
|
+
@_async_handle_keepalive_errors
|
|
585
|
+
async def refresh_lease(self, lease_id):
|
|
586
|
+
"""Refresh a lease continuously with automatic reconnection.
|
|
587
|
+
|
|
588
|
+
This method creates a long-lived gRPC stream that sends keep-alive requests
|
|
589
|
+
periodically to maintain the lease alive. The caller should iterate over
|
|
590
|
+
this generator as long as they want the lease to be kept alive.
|
|
591
|
+
|
|
592
|
+
The stream will yield a response whenever the lease TTL is refreshed by
|
|
593
|
+
the server (typically when TTL drops below a threshold).
|
|
594
|
+
|
|
595
|
+
This method automatically retries on connection errors, allowing recovery
|
|
596
|
+
from temporary ETCD server restarts (as long as the lease hasn't expired).
|
|
597
|
+
|
|
598
|
+
Raises:
|
|
599
|
+
grpc.RpcError: When connection errors exceed max retry attempts or
|
|
600
|
+
lease is permanently not found
|
|
601
|
+
"""
|
|
602
|
+
consecutive_errors = 0
|
|
603
|
+
max_consecutive_errors = 10 # Max consecutive errors before giving up
|
|
604
|
+
base_retry_delay = 1.0 # Base retry delay in seconds
|
|
605
|
+
max_retry_delay = 30.0 # Max retry delay in seconds
|
|
606
|
+
|
|
607
|
+
while True:
|
|
608
|
+
try:
|
|
609
|
+
lease_stub = await self._get_lease_stub()
|
|
610
|
+
keep_alive_request = etcdrpc.LeaseKeepAliveRequest(ID=lease_id)
|
|
611
|
+
# Note: We use iter([request]) for each iteration. The gRPC stream
|
|
612
|
+
# remains open, and etcd server will send keep-alive responses
|
|
613
|
+
# periodically when the TTL approaches expiration.
|
|
614
|
+
async for response in lease_stub.LeaseKeepAlive(
|
|
615
|
+
iter([keep_alive_request]),
|
|
616
|
+
timeout=self.client.timeout,
|
|
617
|
+
credentials=self.client.call_credentials,
|
|
618
|
+
metadata=self.client.metadata,
|
|
619
|
+
):
|
|
620
|
+
# Reset error count on successful response
|
|
621
|
+
consecutive_errors = 0
|
|
622
|
+
yield response
|
|
623
|
+
except grpc.RpcError as exc:
|
|
624
|
+
code = exc.code()
|
|
625
|
+
|
|
626
|
+
# Lease not found (expired or revoked) - permanent failure, stop retrying
|
|
627
|
+
if code == grpc.StatusCode.NOT_FOUND:
|
|
628
|
+
logger.debug(f"Lease {lease_id} not found, stopping keepalive")
|
|
629
|
+
break
|
|
630
|
+
|
|
631
|
+
# Connection errors - retry with exponential backoff
|
|
632
|
+
if code in (
|
|
633
|
+
grpc.StatusCode.UNAVAILABLE,
|
|
634
|
+
grpc.StatusCode.DEADLINE_EXCEEDED,
|
|
635
|
+
grpc.StatusCode.INTERNAL,
|
|
636
|
+
):
|
|
637
|
+
consecutive_errors += 1
|
|
638
|
+
|
|
639
|
+
if consecutive_errors >= max_consecutive_errors:
|
|
640
|
+
logger.error(
|
|
641
|
+
f"Lease {lease_id} keepalive failed after "
|
|
642
|
+
f"{max_consecutive_errors} consecutive errors"
|
|
643
|
+
)
|
|
644
|
+
raise # Exceeded max retries, propagate the error
|
|
645
|
+
|
|
646
|
+
# Exponential backoff with jitter
|
|
647
|
+
retry_delay = min(
|
|
648
|
+
base_retry_delay * (2 ** (consecutive_errors - 1)),
|
|
649
|
+
max_retry_delay
|
|
650
|
+
)
|
|
651
|
+
jitter = random.uniform(0, retry_delay * 0.1)
|
|
652
|
+
actual_delay = retry_delay + jitter
|
|
653
|
+
|
|
654
|
+
logger.warning(
|
|
655
|
+
f"Lease {lease_id} keepalive connection error ({code}), "
|
|
656
|
+
f"retrying in {actual_delay:.1f}s "
|
|
657
|
+
f"(attempt {consecutive_errors}/{max_consecutive_errors})"
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
await asyncio.sleep(actual_delay)
|
|
661
|
+
continue # Retry the loop
|
|
662
|
+
|
|
663
|
+
# Other errors - propagate to let the decorator handle them
|
|
664
|
+
raise
|
|
665
|
+
except GeneratorExit:
|
|
666
|
+
# Generator was closed externally (e.g., garbage collected or iteration stopped)
|
|
667
|
+
break
|
|
668
|
+
|
|
669
|
+
@_async_handle_errors
|
|
670
|
+
async def get_lease_info(self, lease_id):
|
|
671
|
+
"""Get information about a lease."""
|
|
672
|
+
lease_timing_request = etcdrpc.LeaseTimeToLiveRequest(ID=lease_id, keys=True)
|
|
673
|
+
lease_stub = await self._get_lease_stub()
|
|
674
|
+
lease_timing_response = await lease_stub.LeaseTimeToLive(
|
|
675
|
+
lease_timing_request,
|
|
676
|
+
timeout=self.client.timeout,
|
|
677
|
+
credentials=self.client.call_credentials,
|
|
678
|
+
metadata=self.client.metadata,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
# Set trace attributes for lease info
|
|
682
|
+
if is_tracing_available():
|
|
683
|
+
span = get_current_span()
|
|
684
|
+
if span:
|
|
685
|
+
attrs = build_lease_attributes(
|
|
686
|
+
lease_id=lease_id,
|
|
687
|
+
ttl=lease_timing_response.TTL,
|
|
688
|
+
remaining_ttl=getattr(lease_timing_response, 'TTL', None)
|
|
689
|
+
)
|
|
690
|
+
endpoint = str(self.client.endpoint_in_use) if self.client.endpoint_in_use else None
|
|
691
|
+
if endpoint:
|
|
692
|
+
attrs[TraceAttributes.ETCD_ENDPOINT] = endpoint
|
|
693
|
+
for key, value in attrs.items():
|
|
694
|
+
if value is not None:
|
|
695
|
+
span.set_attribute(key, value)
|
|
696
|
+
|
|
697
|
+
return lease_timing_response
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class AsyncLockOperations:
|
|
701
|
+
"""Handles lock operations for AsyncMultiEndpointEtcd3Client."""
|
|
702
|
+
|
|
703
|
+
def __init__(self, client):
|
|
704
|
+
self.client = client
|
|
705
|
+
|
|
706
|
+
async def lock(self, name, ttl=60):
|
|
707
|
+
"""Create a new async lock."""
|
|
708
|
+
return AsyncLock(name, ttl=ttl, etcd_client=self.client)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
class AsyncClusterOperations:
|
|
712
|
+
"""Handles cluster operations for AsyncMultiEndpointEtcd3Client."""
|
|
713
|
+
|
|
714
|
+
def __init__(self, client):
|
|
715
|
+
self.client = client
|
|
716
|
+
|
|
717
|
+
async def add_member(self, urls):
|
|
718
|
+
"""Add a member into the cluster."""
|
|
719
|
+
return await self.client.cluster_manager.add_member(urls)
|
|
720
|
+
|
|
721
|
+
async def remove_member(self, member_id):
|
|
722
|
+
"""Remove an existing member from the cluster."""
|
|
723
|
+
return await self.client.cluster_manager.remove_member(member_id)
|
|
724
|
+
|
|
725
|
+
async def update_member(self, member_id, peer_urls):
|
|
726
|
+
"""Update the configuration of an existing member in the cluster."""
|
|
727
|
+
return await self.client.cluster_manager.update_member(member_id, peer_urls)
|
|
728
|
+
|
|
729
|
+
async def members(self):
|
|
730
|
+
"""List of all members associated with the cluster."""
|
|
731
|
+
async for member in self.client.cluster_manager.members():
|
|
732
|
+
yield member
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
class AsyncMaintenanceOperations:
|
|
736
|
+
"""Handles maintenance operations for AsyncMultiEndpointEtcd3Client."""
|
|
737
|
+
|
|
738
|
+
def __init__(self, client):
|
|
739
|
+
self.client = client
|
|
740
|
+
|
|
741
|
+
async def _get_maintenance_stub(self):
|
|
742
|
+
"""Get the maintenance stub using the current channel with auto-reconnection.
|
|
743
|
+
|
|
744
|
+
Creates a new stub each time to ensure it uses the latest channel,
|
|
745
|
+
which supports automatic reconnection after etcd server restart.
|
|
746
|
+
"""
|
|
747
|
+
channel = await self.client._get_channel()
|
|
748
|
+
return etcdrpc.MaintenanceStub(channel)
|
|
749
|
+
|
|
750
|
+
async def status(self):
|
|
751
|
+
"""Get the status of the responding member."""
|
|
752
|
+
return await self.client.maintenance_manager.status()
|
|
753
|
+
|
|
754
|
+
@_async_handle_errors
|
|
755
|
+
async def compact(self, revision, physical=False):
|
|
756
|
+
"""Compact the event history in etcd."""
|
|
757
|
+
compact_request = etcdrpc.CompactionRequest(
|
|
758
|
+
revision=revision, physical=physical
|
|
759
|
+
)
|
|
760
|
+
maintenance_stub = await self._get_maintenance_stub()
|
|
761
|
+
await maintenance_stub.Compact(
|
|
762
|
+
compact_request,
|
|
763
|
+
timeout=self.client.timeout,
|
|
764
|
+
credentials=self.client.call_credentials,
|
|
765
|
+
metadata=self.client.metadata,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
async def defragment(self):
|
|
769
|
+
"""Defragment a member's backend database to recover storage space."""
|
|
770
|
+
return await self.client.maintenance_manager.defragment()
|
|
771
|
+
|
|
772
|
+
async def hash(self):
|
|
773
|
+
"""Return the hash of the local KV state."""
|
|
774
|
+
return await self.client.maintenance_manager.hash()
|
|
775
|
+
|
|
776
|
+
@_async_handle_errors
|
|
777
|
+
async def create_alarm(self, member_id=0):
|
|
778
|
+
"""Create an alarm.
|
|
779
|
+
|
|
780
|
+
If no member id is given, the alarm is activated for all the
|
|
781
|
+
members of the cluster. Only the `no space` alarm can be raised.
|
|
782
|
+
|
|
783
|
+
:param member_id: The cluster member id to create an alarm to.
|
|
784
|
+
If 0, the alarm is created for all the members
|
|
785
|
+
of the cluster.
|
|
786
|
+
:returns: list of :class:`.Alarm`
|
|
787
|
+
"""
|
|
788
|
+
return await self.client.maintenance_manager.create_alarm(member_id)
|
|
789
|
+
|
|
790
|
+
@_async_handle_generator_errors
|
|
791
|
+
async def list_alarms(self, member_id=0, alarm_type="none"):
|
|
792
|
+
"""List the activated alarms.
|
|
793
|
+
|
|
794
|
+
:param member_id: The cluster member id to list alarms from.
|
|
795
|
+
If 0, alarms are listed for all the members
|
|
796
|
+
of the cluster.
|
|
797
|
+
:param alarm_type: The type of alarm to filter by.
|
|
798
|
+
Can be "none" or "no space".
|
|
799
|
+
:returns: sequence of :class:`.Alarm`
|
|
800
|
+
"""
|
|
801
|
+
async for alarm in self.client.maintenance_manager.list_alarms(
|
|
802
|
+
member_id, alarm_type
|
|
803
|
+
):
|
|
804
|
+
yield alarm
|
|
805
|
+
|
|
806
|
+
@_async_handle_errors
|
|
807
|
+
async def disarm_alarm(self, member_id=0):
|
|
808
|
+
"""Cancel an alarm.
|
|
809
|
+
|
|
810
|
+
:param member_id: The cluster member id to cancel an alarm.
|
|
811
|
+
If 0, the alarm is canceled for all the members
|
|
812
|
+
of the cluster.
|
|
813
|
+
:returns: List of :class:`.Alarm`
|
|
814
|
+
"""
|
|
815
|
+
return await self.client.maintenance_manager.disarm_alarm(member_id)
|
|
816
|
+
|
|
817
|
+
@_async_handle_errors
|
|
818
|
+
async def snapshot(self, file_obj):
|
|
819
|
+
"""Take a snapshot of the database.
|
|
820
|
+
|
|
821
|
+
:param file_obj: A file-like object to write the database contents in.
|
|
822
|
+
"""
|
|
823
|
+
return await self.client.maintenance_manager.snapshot(file_obj)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
class AsyncWatchOperations:
|
|
827
|
+
"""Handles watch operations for AsyncMultiEndpointEtcd3Client."""
|
|
828
|
+
|
|
829
|
+
def __init__(self, client):
|
|
830
|
+
self.client = client
|
|
831
|
+
|
|
832
|
+
async def add_watch_callback(self, *args, **kwargs):
|
|
833
|
+
"""Watch a key or range of keys and call a callback on every response."""
|
|
834
|
+
return await self.client.watch_manager.add_watch_callback(*args, **kwargs)
|
|
835
|
+
|
|
836
|
+
async def add_watch_prefix_callback(self, *args, **kwargs):
|
|
837
|
+
"""Watch a prefix and call a callback on every response."""
|
|
838
|
+
return await self.client.watch_manager.add_watch_prefix_callback(
|
|
839
|
+
*args, **kwargs
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
async def watch_response(self, *args, **kwargs):
|
|
843
|
+
"""Watch a key."""
|
|
844
|
+
return await self.client.watch_manager.watch_response(*args, **kwargs)
|
|
845
|
+
|
|
846
|
+
async def watch(self, *args, **kwargs):
|
|
847
|
+
"""Watch a key."""
|
|
848
|
+
return await self.client.watch_manager.watch(*args, **kwargs)
|
|
849
|
+
|
|
850
|
+
async def watch_prefix_response(self, *args, **kwargs):
|
|
851
|
+
"""Watch a range of keys with a prefix."""
|
|
852
|
+
return await self.client.watch_manager.watch_prefix_response(*args, **kwargs)
|
|
853
|
+
|
|
854
|
+
async def watch_prefix(self, *args, **kwargs):
|
|
855
|
+
"""Watch a range of keys with a prefix."""
|
|
856
|
+
return await self.client.watch_manager.watch_prefix(*args, **kwargs)
|
|
857
|
+
|
|
858
|
+
async def watch_once_response(self, *args, **kwargs):
|
|
859
|
+
"""Watch a key and stop after the first response."""
|
|
860
|
+
return await self.client.watch_manager.watch_once_response(*args, **kwargs)
|
|
861
|
+
|
|
862
|
+
async def watch_once(self, *args, **kwargs):
|
|
863
|
+
"""Watch a key and stop after the first event."""
|
|
864
|
+
return await self.client.watch_manager.watch_once(*args, **kwargs)
|
|
865
|
+
|
|
866
|
+
async def watch_prefix_once_response(self, *args, **kwargs):
|
|
867
|
+
"""Watch a range of keys with a prefix and stop after the first response."""
|
|
868
|
+
return await self.client.watch_manager.watch_prefix_once_response(
|
|
869
|
+
*args, **kwargs
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
async def watch_prefix_once(self, *args, **kwargs):
|
|
873
|
+
"""Watch a range of keys with a prefix and stop after the first event."""
|
|
874
|
+
return await self.client.watch_manager.watch_prefix_once(*args, **kwargs)
|
|
875
|
+
|
|
876
|
+
async def cancel_watch(self, watch_id):
|
|
877
|
+
"""Cancel a watch."""
|
|
878
|
+
return await self.client.watch_manager.cancel_watch(watch_id)
|