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
etcd3/async_watch.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Async watch implementation for etcd3 client.
|
|
2
|
+
|
|
3
|
+
This module provides asynchronous watch functionality for the etcd3 async client.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
import grpc
|
|
10
|
+
import etcdrpc
|
|
11
|
+
|
|
12
|
+
from etcd3 import events
|
|
13
|
+
from etcd3 import exceptions
|
|
14
|
+
from etcd3 import utils
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WatchRequestConfig:
|
|
18
|
+
"""Configuration for watch request.
|
|
19
|
+
|
|
20
|
+
:param range_end: End of the key range to watch
|
|
21
|
+
:type range_end: str or bytes
|
|
22
|
+
:param start_revision: Revision to start watching from
|
|
23
|
+
:type start_revision: int
|
|
24
|
+
:param progress_notify: Whether to receive progress notifications
|
|
25
|
+
:type progress_notify: bool
|
|
26
|
+
:param filters: Event filters to apply
|
|
27
|
+
:type filters: list
|
|
28
|
+
:param prev_kv: Whether to include previous key values
|
|
29
|
+
:type prev_kv: bool
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
range_end=None,
|
|
35
|
+
start_revision=None,
|
|
36
|
+
progress_notify=False,
|
|
37
|
+
filters=None,
|
|
38
|
+
prev_kv=False,
|
|
39
|
+
):
|
|
40
|
+
self.range_end = range_end
|
|
41
|
+
self.start_revision = start_revision
|
|
42
|
+
self.progress_notify = progress_notify
|
|
43
|
+
self.filters = filters
|
|
44
|
+
self.prev_kv = prev_kv
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_log = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AsyncWatch:
|
|
51
|
+
"""Represents an active watch in etcd.
|
|
52
|
+
|
|
53
|
+
:param watch_id: The watch ID returned by etcd
|
|
54
|
+
:type watch_id: int
|
|
55
|
+
:param event_iterator: An optional iterator for watch events
|
|
56
|
+
:type event_iterator: async iterator
|
|
57
|
+
:param etcd_client: The etcd client instance
|
|
58
|
+
:type etcd_client: AsyncMultiEndpointEtcd3Client
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, watch_id, event_iterator=None, etcd_client=None):
|
|
62
|
+
self.watch_id = watch_id
|
|
63
|
+
self.etcd_client = etcd_client
|
|
64
|
+
self._event_iterator = event_iterator
|
|
65
|
+
|
|
66
|
+
async def cancel(self):
|
|
67
|
+
"""Cancel the watch.
|
|
68
|
+
|
|
69
|
+
This method cancels the active watch by calling the etcd client's cancel_watch method.
|
|
70
|
+
"""
|
|
71
|
+
await self.etcd_client.cancel_watch(self.watch_id)
|
|
72
|
+
|
|
73
|
+
def get_event_iterator(self):
|
|
74
|
+
"""Get the iterator for watch events.
|
|
75
|
+
|
|
76
|
+
:returns: The iterator for watch events
|
|
77
|
+
:raises ValueError: If the iterator is undefined
|
|
78
|
+
"""
|
|
79
|
+
if self._event_iterator is not None:
|
|
80
|
+
return self._event_iterator
|
|
81
|
+
|
|
82
|
+
raise ValueError("Undefined event iterator")
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def iterator(self):
|
|
86
|
+
"""Get the iterator for watch events (property version).
|
|
87
|
+
|
|
88
|
+
:returns: The iterator for watch events
|
|
89
|
+
:raises ValueError: If the iterator is undefined
|
|
90
|
+
"""
|
|
91
|
+
return self.get_event_iterator()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AsyncWatcher:
|
|
95
|
+
"""Manages watch operations for etcd.
|
|
96
|
+
|
|
97
|
+
This class handles the creation and management of watch streams, callbacks,
|
|
98
|
+
and watch requests to etcd.
|
|
99
|
+
|
|
100
|
+
:param watchstub: The gRPC watch stub
|
|
101
|
+
:type watchstub: etcdrpc.WatchStub
|
|
102
|
+
:param timeout: The timeout for watch operations
|
|
103
|
+
:type timeout: int or float
|
|
104
|
+
:param call_credentials: The gRPC call credentials
|
|
105
|
+
:type call_credentials: grpc.CallCredentials
|
|
106
|
+
:param metadata: The gRPC metadata
|
|
107
|
+
:type metadata: tuple
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, watchstub, timeout=None, call_credentials=None, metadata=None):
|
|
111
|
+
self.timeout = timeout
|
|
112
|
+
self._watch_stub = watchstub
|
|
113
|
+
self._credentials = call_credentials
|
|
114
|
+
self._metadata = metadata
|
|
115
|
+
|
|
116
|
+
self._lock = asyncio.Lock()
|
|
117
|
+
self._request_queue = asyncio.Queue(maxsize=10)
|
|
118
|
+
self._callbacks = {}
|
|
119
|
+
self._task = None
|
|
120
|
+
self._new_watch_event = asyncio.Event()
|
|
121
|
+
self._new_watch = None
|
|
122
|
+
self._stopping = False
|
|
123
|
+
|
|
124
|
+
def _create_watch_request(self, key, config=None, **kwargs):
|
|
125
|
+
"""Create a watch request.
|
|
126
|
+
|
|
127
|
+
:param key: The key to watch
|
|
128
|
+
:type key: str or bytes
|
|
129
|
+
:param config: Watch request configuration
|
|
130
|
+
:type config: WatchRequestConfig
|
|
131
|
+
:param kwargs: Additional keyword arguments for backward compatibility
|
|
132
|
+
:return: WatchRequest instance
|
|
133
|
+
"""
|
|
134
|
+
create_watch = etcdrpc.WatchCreateRequest()
|
|
135
|
+
create_watch.key = utils.to_bytes(key)
|
|
136
|
+
|
|
137
|
+
# Use config object if provided, otherwise use kwargs or defaults
|
|
138
|
+
if config is None:
|
|
139
|
+
# Create config from kwargs for backward compatibility
|
|
140
|
+
config = WatchRequestConfig(
|
|
141
|
+
range_end=kwargs.get("range_end"),
|
|
142
|
+
start_revision=kwargs.get("start_revision"),
|
|
143
|
+
progress_notify=kwargs.get("progress_notify", False),
|
|
144
|
+
filters=kwargs.get("filters"),
|
|
145
|
+
prev_kv=kwargs.get("prev_kv", False),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if config.range_end is not None:
|
|
149
|
+
create_watch.range_end = utils.to_bytes(config.range_end)
|
|
150
|
+
if config.start_revision is not None:
|
|
151
|
+
create_watch.start_revision = config.start_revision
|
|
152
|
+
if config.progress_notify:
|
|
153
|
+
create_watch.progress_notify = config.progress_notify
|
|
154
|
+
if config.filters is not None:
|
|
155
|
+
create_watch.filters.extend(config.filters)
|
|
156
|
+
if config.prev_kv:
|
|
157
|
+
create_watch.prev_kv = config.prev_kv
|
|
158
|
+
return etcdrpc.WatchRequest(create_request=create_watch)
|
|
159
|
+
|
|
160
|
+
async def add_callback(self, key, callback, config=None, **kwargs):
|
|
161
|
+
"""Add a callback for watch events.
|
|
162
|
+
|
|
163
|
+
:param key: The key to watch
|
|
164
|
+
:type key: str or bytes
|
|
165
|
+
:param callback: The callback function to call on watch events
|
|
166
|
+
:type callback: callable
|
|
167
|
+
:param config: Watch request configuration
|
|
168
|
+
:type config: WatchRequestConfig
|
|
169
|
+
:param kwargs: Additional keyword arguments for backward compatibility
|
|
170
|
+
:returns: The watch ID
|
|
171
|
+
:raises WatchTimedOut: If the watch creation times out
|
|
172
|
+
"""
|
|
173
|
+
rq = self._create_watch_request(key, config=config, **kwargs)
|
|
174
|
+
|
|
175
|
+
# Start the task if it is not yet running.
|
|
176
|
+
async with self._lock:
|
|
177
|
+
# Wait for exiting task to close
|
|
178
|
+
if self._stopping:
|
|
179
|
+
if self._task:
|
|
180
|
+
await self._task
|
|
181
|
+
self._task = None
|
|
182
|
+
self._stopping = False
|
|
183
|
+
|
|
184
|
+
# Start the task if it is not yet running.
|
|
185
|
+
if not self._task:
|
|
186
|
+
self._task = asyncio.create_task(self._run())
|
|
187
|
+
|
|
188
|
+
# Only one create watch request can be pending at a time
|
|
189
|
+
while self._new_watch is not None:
|
|
190
|
+
await self._new_watch_event.wait()
|
|
191
|
+
self._new_watch_event.clear()
|
|
192
|
+
|
|
193
|
+
# Submit a create watch request.
|
|
194
|
+
new_watch = _NewWatch(callback)
|
|
195
|
+
await self._request_queue.put(rq)
|
|
196
|
+
self._new_watch = new_watch
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
# Wait for the request to be completed, or timeout.
|
|
200
|
+
try:
|
|
201
|
+
await asyncio.wait_for(
|
|
202
|
+
self._new_watch_event.wait(), timeout=self.timeout
|
|
203
|
+
)
|
|
204
|
+
self._new_watch_event.clear()
|
|
205
|
+
except asyncio.TimeoutError as exc:
|
|
206
|
+
# If the request not completed yet, then raise a timeout
|
|
207
|
+
# exception.
|
|
208
|
+
if new_watch.id is None and new_watch.err is None:
|
|
209
|
+
raise exceptions.WatchTimedOut() from exc
|
|
210
|
+
|
|
211
|
+
# Raise an exception if the watch request failed.
|
|
212
|
+
if new_watch.err:
|
|
213
|
+
raise new_watch.err
|
|
214
|
+
finally:
|
|
215
|
+
# Wake up tasks stuck on add_callback call if any.
|
|
216
|
+
async with self._lock:
|
|
217
|
+
self._new_watch = None
|
|
218
|
+
self._new_watch_event.set()
|
|
219
|
+
|
|
220
|
+
return new_watch.id
|
|
221
|
+
|
|
222
|
+
async def cancel(self, watch_id):
|
|
223
|
+
"""Cancel a watch.
|
|
224
|
+
|
|
225
|
+
:param watch_id: The ID of the watch to cancel
|
|
226
|
+
:type watch_id: int
|
|
227
|
+
"""
|
|
228
|
+
async with self._lock:
|
|
229
|
+
callback = self._callbacks.pop(watch_id, None)
|
|
230
|
+
if not callback:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
await self._cancel_no_lock(watch_id)
|
|
234
|
+
|
|
235
|
+
async def _run(self):
|
|
236
|
+
callback_err = None
|
|
237
|
+
try:
|
|
238
|
+
response_iter = self._watch_stub.Watch(
|
|
239
|
+
self._new_request_iter(),
|
|
240
|
+
credentials=self._credentials,
|
|
241
|
+
metadata=self._metadata,
|
|
242
|
+
)
|
|
243
|
+
async for rs in response_iter:
|
|
244
|
+
await self._handle_response(rs)
|
|
245
|
+
|
|
246
|
+
except grpc.RpcError as err:
|
|
247
|
+
callback_err = err
|
|
248
|
+
|
|
249
|
+
finally:
|
|
250
|
+
async with self._lock:
|
|
251
|
+
self._stopping = True
|
|
252
|
+
if self._new_watch:
|
|
253
|
+
self._new_watch.err = callback_err
|
|
254
|
+
self._new_watch_event.set()
|
|
255
|
+
|
|
256
|
+
callbacks = self._callbacks
|
|
257
|
+
self._callbacks = {}
|
|
258
|
+
|
|
259
|
+
# Rotate request queue
|
|
260
|
+
await self._request_queue.put(None)
|
|
261
|
+
self._request_queue = asyncio.Queue(maxsize=10)
|
|
262
|
+
|
|
263
|
+
for callback in callbacks.values():
|
|
264
|
+
await _safe_callback(callback, callback_err)
|
|
265
|
+
|
|
266
|
+
async def _handle_response(self, rs):
|
|
267
|
+
async with self._lock:
|
|
268
|
+
if rs.created:
|
|
269
|
+
# If the new watch request has already expired then cancel the
|
|
270
|
+
# created watch right away.
|
|
271
|
+
if not self._new_watch:
|
|
272
|
+
await self._cancel_no_lock(rs.watch_id)
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
if rs.compact_revision != 0:
|
|
276
|
+
self._new_watch.err = exceptions.RevisionCompactedError(
|
|
277
|
+
rs.compact_revision
|
|
278
|
+
)
|
|
279
|
+
self._new_watch_event.set()
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
self._callbacks[rs.watch_id] = self._new_watch.callback
|
|
283
|
+
self._new_watch.id = rs.watch_id
|
|
284
|
+
self._new_watch_event.set()
|
|
285
|
+
|
|
286
|
+
callback = self._callbacks.get(rs.watch_id)
|
|
287
|
+
|
|
288
|
+
# Ignore leftovers from canceled watches.
|
|
289
|
+
if not callback:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
# The watcher can be safely reused, but adding a new event
|
|
293
|
+
# to indicate that the revision is already compacted
|
|
294
|
+
# requires api change which would break all users of this
|
|
295
|
+
# module. So, raising an exception if a watcher is still
|
|
296
|
+
# alive.
|
|
297
|
+
if rs.compact_revision != 0:
|
|
298
|
+
err = exceptions.RevisionCompactedError(rs.compact_revision)
|
|
299
|
+
await _safe_callback(callback, err)
|
|
300
|
+
await self.cancel(rs.watch_id)
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
# Call the callback even when there are no events in the watch
|
|
304
|
+
# response so as not to ignore progress notify responses.
|
|
305
|
+
if rs.events or not (rs.created or rs.canceled):
|
|
306
|
+
new_events = [events.new_event(event) for event in rs.events]
|
|
307
|
+
response = WatchResponse(rs.header, new_events)
|
|
308
|
+
await _safe_callback(callback, response)
|
|
309
|
+
|
|
310
|
+
async def _cancel_no_lock(self, watch_id):
|
|
311
|
+
cancel_watch = etcdrpc.WatchCancelRequest()
|
|
312
|
+
cancel_watch.watch_id = watch_id
|
|
313
|
+
rq = etcdrpc.WatchRequest(cancel_request=cancel_watch)
|
|
314
|
+
await self._request_queue.put(rq)
|
|
315
|
+
|
|
316
|
+
async def close(self):
|
|
317
|
+
"""Close the watcher and clean up resources.
|
|
318
|
+
|
|
319
|
+
This method stops the watcher task and cleans up any resources used by the watcher.
|
|
320
|
+
"""
|
|
321
|
+
task_to_wait = None
|
|
322
|
+
async with self._lock:
|
|
323
|
+
if self._task and not self._stopping:
|
|
324
|
+
# Signal the task to stop
|
|
325
|
+
await self._request_queue.put(None)
|
|
326
|
+
self._stopping = True
|
|
327
|
+
task_to_wait = self._task
|
|
328
|
+
self._task = None
|
|
329
|
+
else:
|
|
330
|
+
self._stopping = True
|
|
331
|
+
|
|
332
|
+
# Wait for the task to complete if we have one
|
|
333
|
+
if task_to_wait is not None:
|
|
334
|
+
try:
|
|
335
|
+
await task_to_wait
|
|
336
|
+
except asyncio.CancelledError:
|
|
337
|
+
pass
|
|
338
|
+
except Exception:
|
|
339
|
+
_log.exception("Error waiting for watcher task to complete")
|
|
340
|
+
|
|
341
|
+
async def watch(self, key, **kwargs):
|
|
342
|
+
"""Watch a key and return an event iterator.
|
|
343
|
+
|
|
344
|
+
:param key: The key to watch
|
|
345
|
+
:type key: str or bytes
|
|
346
|
+
:param config: Watch request configuration
|
|
347
|
+
:type config: WatchRequestConfig
|
|
348
|
+
:param kwargs: Additional keyword arguments for backward compatibility
|
|
349
|
+
:returns: An async iterator of watch responses
|
|
350
|
+
"""
|
|
351
|
+
rq = self._create_watch_request(key, config=None, **kwargs)
|
|
352
|
+
|
|
353
|
+
response_iter = self._watch_stub.Watch(
|
|
354
|
+
self._watch_request_iter(rq),
|
|
355
|
+
credentials=self._credentials,
|
|
356
|
+
metadata=self._metadata,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
async for rs in response_iter:
|
|
360
|
+
if rs.created:
|
|
361
|
+
continue
|
|
362
|
+
if rs.compact_revision != 0:
|
|
363
|
+
raise exceptions.RevisionCompactedError(rs.compact_revision)
|
|
364
|
+
if rs.canceled:
|
|
365
|
+
break
|
|
366
|
+
if rs.events:
|
|
367
|
+
new_events = [events.new_event(event) for event in rs.events]
|
|
368
|
+
yield WatchResponse(rs.header, new_events)
|
|
369
|
+
|
|
370
|
+
async def watch_once(self, key, timeout=None, **kwargs):
|
|
371
|
+
"""Watch a key and return the first response.
|
|
372
|
+
|
|
373
|
+
:param key: The key to watch
|
|
374
|
+
:type key: str or bytes
|
|
375
|
+
:param timeout: Timeout in seconds
|
|
376
|
+
:type timeout: float or None
|
|
377
|
+
:param config: Watch request configuration
|
|
378
|
+
:type config: WatchRequestConfig
|
|
379
|
+
:param kwargs: Additional keyword arguments for backward compatibility
|
|
380
|
+
:returns: The first WatchResponse
|
|
381
|
+
:raises WatchTimedOut: If timeout is reached
|
|
382
|
+
"""
|
|
383
|
+
rq = self._create_watch_request(key, config=None, **kwargs)
|
|
384
|
+
|
|
385
|
+
response_iter = self._watch_stub.Watch(
|
|
386
|
+
self._watch_request_iter(rq),
|
|
387
|
+
credentials=self._credentials,
|
|
388
|
+
metadata=self._metadata,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
async def iter_with_timeout():
|
|
392
|
+
async for rs in response_iter:
|
|
393
|
+
yield rs
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
async with asyncio.timeout(timeout):
|
|
397
|
+
async for rs in iter_with_timeout():
|
|
398
|
+
if rs.created:
|
|
399
|
+
continue
|
|
400
|
+
if rs.compact_revision != 0:
|
|
401
|
+
raise exceptions.RevisionCompactedError(rs.compact_revision)
|
|
402
|
+
if rs.canceled:
|
|
403
|
+
raise exceptions.WatchTimedOut()
|
|
404
|
+
if rs.events:
|
|
405
|
+
new_events = [events.new_event(event) for event in rs.events]
|
|
406
|
+
return WatchResponse(rs.header, new_events)
|
|
407
|
+
except asyncio.TimeoutError:
|
|
408
|
+
raise exceptions.WatchTimedOut()
|
|
409
|
+
|
|
410
|
+
async def _watch_request_iter(self, initial_request):
|
|
411
|
+
"""Create an iterator that yields watch requests.
|
|
412
|
+
|
|
413
|
+
:param initial_request: The initial watch request to send
|
|
414
|
+
:type initial_request: etcdrpc.WatchRequest
|
|
415
|
+
:yields: WatchRequest instances
|
|
416
|
+
"""
|
|
417
|
+
yield initial_request
|
|
418
|
+
# Keep the stream open but don't yield more requests
|
|
419
|
+
# This is a simple implementation - a more robust one would handle
|
|
420
|
+
# additional requests coming in while watching
|
|
421
|
+
try:
|
|
422
|
+
async for _ in self._new_request_iter():
|
|
423
|
+
pass
|
|
424
|
+
except asyncio.CancelledError:
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
async def _new_request_iter(self):
|
|
428
|
+
while True:
|
|
429
|
+
rq = await self._request_queue.get()
|
|
430
|
+
if rq is None:
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
yield rq
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class WatchResponse:
|
|
437
|
+
"""Represents a watch response from etcd.
|
|
438
|
+
|
|
439
|
+
:param header: The response header
|
|
440
|
+
:type header: etcdrpc.ResponseHeader
|
|
441
|
+
:param events_list: The list of events in the response
|
|
442
|
+
:type events_list: list
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
def __init__(self, header, events_list):
|
|
446
|
+
self.header = header
|
|
447
|
+
self.events = events_list
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class _NewWatch:
|
|
451
|
+
def __init__(self, callback):
|
|
452
|
+
self.callback = callback
|
|
453
|
+
self.id = None
|
|
454
|
+
self.err = None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
async def _safe_callback(callback, response_or_err):
|
|
458
|
+
try:
|
|
459
|
+
await callback(response_or_err)
|
|
460
|
+
|
|
461
|
+
except Exception as exc:
|
|
462
|
+
_log.exception("Watch callback failed: %s", exc)
|