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/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)