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_client.py ADDED
@@ -0,0 +1,1495 @@
1
+ """Async etcd3 client implementation using grpc.aio.
2
+
3
+ This module provides asynchronous client classes for interacting with etcd v3 API
4
+ using the asynchronous gRPC client from grpcio package.
5
+ """
6
+
7
+ import asyncio
8
+ import functools
9
+ import random
10
+ import threading
11
+ from dataclasses import dataclass, field
12
+ from typing import Optional, Literal, Any
13
+
14
+ import grpc
15
+ import etcdrpc
16
+
17
+ from etcd3 import exceptions
18
+ from etcd3 import utils
19
+ from etcd3 import transactions
20
+ from etcd3.async_endpoint import AsyncEndpoint
21
+ from etcd3.shared import (
22
+ KVMetadata,
23
+ EXCEPTIONS_BY_CODE,
24
+ FAILED_EP_CODES,
25
+ Transactions,
26
+ EtcdTokenCallCredentials,
27
+ RequestBuilder,
28
+ )
29
+ from etcd3.async_managers import (
30
+ AsyncWatchManager,
31
+ AsyncClusterManager,
32
+ AsyncMaintenanceManager,
33
+ )
34
+ from etcd3.exporters.prometheus import get_metrics_interface, monitor_request
35
+ from etcd3.log import log_operation_async
36
+ from etcd3.async_client_extensions import (
37
+ AsyncKVOperations,
38
+ AsyncLeaseOperations,
39
+ AsyncLockOperations,
40
+ AsyncClusterOperations,
41
+ AsyncMaintenanceOperations,
42
+ AsyncWatchOperations,
43
+ )
44
+
45
+
46
+ @dataclass(kw_only=True)
47
+ class RangeRequestConfig:
48
+ """Configuration for range request.
49
+
50
+ :param range_end: End of the key range
51
+ :type range_end: str or bytes
52
+ :param limit: Maximum number of keys to return
53
+ :type limit: int
54
+ :param revision: Revision to get
55
+ :type revision: int
56
+ :param sort_order: Sort order ('ascend' or 'descend')
57
+ :type sort_order: str
58
+ :param sort_target: Sort target ('key', 'version', 'create', 'mod')
59
+ :type sort_target: str
60
+ :param serializable: Whether to use serializable read
61
+ :type serializable: bool
62
+ :param keys_only: Whether to return only keys
63
+ :type keys_only: bool
64
+ :param count_only: Whether to return only count
65
+ :type count_only: bool
66
+ :param min_mod_revision: Minimum modification revision
67
+ :type min_mod_revision: int
68
+ :param max_mod_revision: Maximum modification revision
69
+ :type max_mod_revision: int
70
+ :param min_create_revision: Minimum creation revision
71
+ :type min_create_revision: int
72
+ :param max_create_revision: Maximum creation revision
73
+ :type max_create_revision: int
74
+ """
75
+
76
+ range_end: Optional[bytes | str] = None
77
+ limit: Optional[int] = None
78
+ revision: Optional[int] = None
79
+ sort_order: Optional[Literal["ascend", "descend"]] = None
80
+ sort_target: Literal["key", "version", "create", "mod"] = "key"
81
+ serializable: bool = False
82
+ keys_only: bool = False
83
+ count_only: bool = False
84
+ min_mod_revision: Optional[int] = None
85
+ max_mod_revision: Optional[int] = None
86
+ min_create_revision: Optional[int] = None
87
+ max_create_revision: Optional[int] = None
88
+
89
+ def __post_init__(self):
90
+ if self.limit is not None and self.limit < 1:
91
+ raise ValueError("limit must be a positive integer")
92
+ if self.revision is not None and self.revision < 0:
93
+ raise ValueError("revision must be a non-negative integer")
94
+ if self.min_mod_revision is not None and self.min_mod_revision < 0:
95
+ raise ValueError("min_mod_revision must be a non-negative integer")
96
+ if self.max_mod_revision is not None and self.max_mod_revision < 0:
97
+ raise ValueError("max_mod_revision must be a non-negative integer")
98
+ if self.min_create_revision is not None and self.min_create_revision < 0:
99
+ raise ValueError("min_create_revision must be a non-negative integer")
100
+ if self.max_create_revision is not None and self.max_create_revision < 0:
101
+ raise ValueError("max_create_revision must be a non-negative integer")
102
+
103
+
104
+ @dataclass(kw_only=True)
105
+ class AsyncClientConfig:
106
+ """Configuration for async client creation.
107
+
108
+ :param host: etcd host
109
+ :type host: str
110
+ :param port: etcd port
111
+ :type port: int
112
+ :param ca_cert: CA certificate path
113
+ :type ca_cert: str
114
+ :param cert_key: client certificate key path
115
+ :type cert_key: str
116
+ :param cert_cert: client certificate path
117
+ :type cert_cert: str
118
+ :param timeout: request timeout in seconds
119
+ :type timeout: int or float
120
+ :param user: username for authentication
121
+ :type user: str
122
+ :param password: password for authentication
123
+ :type password: str
124
+ :param grpc_options: additional gRPC options
125
+ :type grpc_options: dict[str, Any]
126
+ """
127
+
128
+ host: str = "localhost"
129
+ port: int = 2379
130
+ ca_cert: Optional[str] = None
131
+ cert_key: Optional[str] = None
132
+ cert_cert: Optional[str] = None
133
+ timeout: Optional[float] = None
134
+ user: Optional[str] = None
135
+ password: Optional[str] = None
136
+ grpc_options: dict[str, Any] = field(default_factory=dict)
137
+
138
+ def __post_init__(self):
139
+ if self.port < 1 or self.port > 65535:
140
+ raise ValueError("port must be between 1 and 65535")
141
+ if self.timeout is not None and self.timeout <= 0:
142
+ raise ValueError("timeout must be a positive number")
143
+
144
+
145
+ @dataclass(kw_only=True)
146
+ class AsyncMultiEndpointConfig:
147
+ """Configuration for AsyncMultiEndpointEtcd3Client creation.
148
+
149
+ :param endpoints: Endpoints to use
150
+ :type endpoints: Iterable[Endpoint]
151
+ :param timeout: Timeout for all RPC in seconds
152
+ :type timeout: float, optional
153
+ :param user: Username for authentication
154
+ :type user: str, optional
155
+ :param password: Password for authentication
156
+ :type password: str, optional
157
+ :param failover: Failover between endpoints, default False
158
+ :type failover: bool
159
+ :param max_retries: Maximum number of retries for transient errors
160
+ :type max_retries: int
161
+ :param retry_backoff: Initial backoff time in seconds for retries
162
+ :type retry_backoff: float
163
+ :param retry_max_backoff: Maximum backoff time in seconds for retries
164
+ :type retry_max_backoff: float
165
+ :param load_balancer: Load balancing strategy
166
+ :type load_balancer: LoadBalancerStrategy
167
+ :param health_check_interval: Health check interval in seconds
168
+ :type health_check_interval: float
169
+ """
170
+
171
+ endpoints: Optional[list] = None
172
+ timeout: Optional[float] = None
173
+ user: Optional[str] = None
174
+ password: Optional[str] = None
175
+ failover: bool = False
176
+ max_retries: int = 3
177
+ retry_backoff: float = 0.1
178
+ retry_max_backoff: float = 10.0
179
+ load_balancer: utils.LoadBalancerStrategy = utils.LoadBalancerStrategy.PRIORITY
180
+ health_check_interval: float = 30.0
181
+
182
+ def __post_init__(self):
183
+ if self.max_retries < 0:
184
+ raise ValueError("max_retries must be a non-negative integer")
185
+ if self.retry_backoff < 0:
186
+ raise ValueError("retry_backoff must be a non-negative number")
187
+ if self.retry_max_backoff < 0:
188
+ raise ValueError("retry_max_backoff must be a non-negative number")
189
+ if self.retry_backoff > self.retry_max_backoff:
190
+ raise ValueError("retry_backoff must not exceed retry_max_backoff")
191
+ if self.health_check_interval <= 0:
192
+ raise ValueError("health_check_interval must be a positive number")
193
+
194
+
195
+ class AsyncMultiEndpointEtcd3Client:
196
+ """
197
+ Async etcd v3 API client with multiple endpoints.
198
+
199
+ When failover is enabled, requests still will not be auto-retried.
200
+ Instead, the application may retry the request, and the ``Etcd3Client``
201
+ will then attempt to send it to a different endpoint that has not recently
202
+ failed. If all configured endpoints have failed and are not ready to be
203
+ retried, an ``exceptions.NoServerAvailableError`` will be raised.
204
+
205
+ :param endpoints: Endpoints to use in lieu of host and port
206
+ :type endpoints: Iterable(Endpoint), optional
207
+ :param timeout: Timeout for all RPC in seconds
208
+ :type timeout: int or float, optional
209
+ :param user: Username for authentication
210
+ :type user: str, optional
211
+ :param password: Password for authentication
212
+ :type password: str, optional
213
+ :param bool failover: Failover between endpoints, default False
214
+ :param int max_retries: Maximum number of retries for transient errors
215
+ :param float retry_backoff: Initial backoff time in seconds for retries
216
+ :param float retry_max_backoff: Maximum backoff time in seconds for retries
217
+ """
218
+
219
+ def __init__(
220
+ self,
221
+ config: Optional[AsyncMultiEndpointConfig] = None,
222
+ endpoints: Optional[list] = None,
223
+ timeout: Optional[float] = None,
224
+ user: Optional[str] = None,
225
+ password: Optional[str] = None,
226
+ failover: bool = False,
227
+ max_retries: int = 3,
228
+ retry_backoff: float = 0.1,
229
+ retry_max_backoff: float = 10.0,
230
+ load_balancer: utils.LoadBalancerStrategy = utils.LoadBalancerStrategy.PRIORITY,
231
+ health_check_interval: float = 30.0,
232
+ ):
233
+ if config is not None:
234
+ endpoints = config.endpoints
235
+ timeout = config.timeout
236
+ user = config.user
237
+ password = config.password
238
+ failover = config.failover
239
+ max_retries = config.max_retries
240
+ retry_backoff = config.retry_backoff
241
+ retry_max_backoff = config.retry_max_backoff
242
+ load_balancer = config.load_balancer
243
+ health_check_interval = config.health_check_interval
244
+
245
+ if endpoints is None:
246
+ raise ValueError("Endpoints must be provided")
247
+
248
+ if not endpoints:
249
+ raise ValueError("Endpoints list cannot be empty")
250
+
251
+ self.metadata = None
252
+ self.failover = failover
253
+ self.max_retries = max_retries
254
+ self.load_balancer = load_balancer
255
+ self.health_check_interval = health_check_interval
256
+ self._rr_index = 0 # Round-robin index
257
+ self.retry_backoff = retry_backoff
258
+ self.retry_max_backoff = retry_max_backoff
259
+
260
+ # Lock for synchronous code (setter, property access)
261
+ self._endpoint_lock = threading.Lock()
262
+ # Lock for asynchronous operations in _get_channel
263
+ self._async_endpoint_lock = asyncio.Lock()
264
+
265
+ self.endpoints = {ep.netloc: ep for ep in endpoints}
266
+ self._current_ep_label = random.choice(list(self.endpoints.keys()))
267
+
268
+ self._auth_done = False
269
+ self._auth_lock = asyncio.Lock()
270
+
271
+ self.timeout = timeout
272
+ self.call_credentials = None
273
+ cred_params = [c is not None for c in (user, password)]
274
+
275
+ if all(cred_params):
276
+ self._user = user
277
+ self._password = password
278
+ elif any(cred_params):
279
+ raise ValueError(
280
+ "if using authentication credentials both user and password "
281
+ "must be specified."
282
+ )
283
+ else:
284
+ self._user = None
285
+ self._password = None
286
+
287
+ self.watch_manager = AsyncWatchManager(self)
288
+ self.cluster_manager = AsyncClusterManager(self)
289
+ self.maintenance_manager = AsyncMaintenanceManager(self)
290
+
291
+ self.metrics = get_metrics_interface()
292
+
293
+ self.kv = AsyncKVOperations(self)
294
+ self.lease_ops = AsyncLeaseOperations(self)
295
+ self.lock_ops = AsyncLockOperations(self)
296
+ self.cluster_ops = AsyncClusterOperations(self)
297
+ self.maintenance_ops = AsyncMaintenanceOperations(self)
298
+ self.watch_ops = AsyncWatchOperations(self)
299
+ self.transactions = Transactions()
300
+
301
+ async def _init_auth(self):
302
+ """Initialize authentication asynchronously."""
303
+ if self._auth_done:
304
+ return
305
+
306
+ async with self._auth_lock:
307
+ if self._auth_done:
308
+ return
309
+
310
+ if self._user and self._password:
311
+ # Create auth stub with auto-reconnection
312
+ channel = await self._get_channel()
313
+ auth_stub = etcdrpc.AuthStub(channel)
314
+
315
+ auth_request = etcdrpc.AuthenticateRequest(
316
+ name=self._user, password=self._password
317
+ )
318
+
319
+ resp = await auth_stub.Authenticate(auth_request, timeout=self.timeout)
320
+ self.metadata = (("token", resp.token),)
321
+ self.call_credentials = grpc.metadata_call_credentials(
322
+ EtcdTokenCallCredentials(resp.token)
323
+ )
324
+
325
+ self._auth_done = True
326
+
327
+ @property
328
+ def _current_endpoint_label(self):
329
+ return self._current_ep_label
330
+
331
+ @_current_endpoint_label.setter
332
+ def _current_endpoint_label(self, value):
333
+ with self._endpoint_lock:
334
+ self._current_ep_label = value
335
+
336
+ @property
337
+ def endpoint_in_use(self):
338
+ """Get the current endpoint in use."""
339
+ if self._current_endpoint_label is None:
340
+ return None
341
+ return self.endpoints[self._current_endpoint_label]
342
+
343
+ @property
344
+ def channel(self):
345
+ """
346
+ Get an available channel on the first node that's not failed.
347
+
348
+ This property supports automatic reconnection when the
349
+ connection becomes stale (e.g., after etcd server restart).
350
+ If the current endpoint's channel is not healthy, it will
351
+ be rebuilt automatically.
352
+
353
+ Raises an exception if no node is available
354
+ """
355
+ with self._endpoint_lock:
356
+ try:
357
+ endpoint = self.endpoint_in_use
358
+ if endpoint is None:
359
+ raise ValueError("No current endpoint")
360
+
361
+ channel = endpoint.use()
362
+ # Record endpoint usage
363
+ self.metrics.record_endpoint_usage(str(self.endpoint_in_use))
364
+ return channel
365
+ except ValueError:
366
+ if not self.failover:
367
+ raise
368
+ for label, endpoint in self.endpoints.items():
369
+ if endpoint.is_failed():
370
+ continue
371
+ self._current_ep_label = label
372
+ channel = self.endpoint_in_use.use()
373
+ # Record endpoint usage for the new endpoint
374
+ self.metrics.record_endpoint_usage(str(self.endpoint_in_use))
375
+ return channel
376
+ raise exceptions.NoServerAvailableError(
377
+ "No endpoint available and not failed"
378
+ )
379
+
380
+ def _get_next_endpoint(self):
381
+ """Get the next endpoint based on load balancing strategy.
382
+
383
+ :returns: A tuple of (endpoint_label, endpoint) or (None, None)
384
+ if no healthy endpoint
385
+ """
386
+ healthy_endpoints = [
387
+ (label, ep) for label, ep in self.endpoints.items() if not ep.is_failed()
388
+ ]
389
+
390
+ if not healthy_endpoints:
391
+ return None, None
392
+
393
+ if self.load_balancer == utils.LoadBalancerStrategy.ROUND_ROBIN:
394
+ # Round-robin: select next endpoint in order
395
+ if not hasattr(self, "_rr_index"):
396
+ self._rr_index = 0
397
+ self._rr_index = (self._rr_index + 1) % len(healthy_endpoints)
398
+ return healthy_endpoints[self._rr_index - 1]
399
+
400
+ elif self.load_balancer == utils.LoadBalancerStrategy.RANDOM:
401
+ # Random: select random endpoint
402
+ import random
403
+
404
+ return random.choice(healthy_endpoints)
405
+
406
+ elif self.load_balancer == utils.LoadBalancerStrategy.STICKY:
407
+ # Sticky: keep current endpoint if healthy
408
+ current = self.endpoint_in_use
409
+ if current is not None and not current.is_failed():
410
+ return self._current_ep_label, current
411
+ # Fallback to first healthy endpoint
412
+ return healthy_endpoints[0]
413
+
414
+ else:
415
+ # PRIORITY (default): keep current endpoint if healthy
416
+ current = self.endpoint_in_use
417
+ if current is not None and not current.is_failed():
418
+ return self._current_ep_label, current
419
+ # Fallback to first healthy endpoint
420
+ return healthy_endpoints[0]
421
+
422
+ async def _try_rebuild_channel(self, endpoint):
423
+ """尝试重建通道,成功返回 True,失败返回 False"""
424
+ try:
425
+ await endpoint.rebuild_channel()
426
+ return True
427
+ except Exception:
428
+ return False
429
+
430
+ async def _get_channel(self):
431
+ """获取可用通道,支持自动重连。
432
+
433
+ 采用两段式锁策略:
434
+ 1. 锁内快速检查状态
435
+ 2. 锁外执行 I/O 操作
436
+ 3. 锁内更新状态
437
+ """
438
+ # 阶段1:快速检查状态(锁内)
439
+ async with self._async_endpoint_lock:
440
+ endpoint = self.endpoint_in_use
441
+ if endpoint is None:
442
+ raise ValueError("No current endpoint")
443
+
444
+ # 检查 endpoint 是否已恢复健康
445
+ if endpoint.is_failed() and endpoint.is_healthy():
446
+ # 恢复健康状态,重置失败标记
447
+ endpoint.last_failed = 0
448
+ endpoint.in_use = True
449
+ self.metrics.record_endpoint_recovery(str(endpoint))
450
+ channel = endpoint.channel
451
+ self.metrics.record_endpoint_usage(str(endpoint))
452
+ return channel
453
+
454
+ needs_rebuild = endpoint.is_failed()
455
+
456
+ # 阶段2:在锁外执行 I/O(关键优化)
457
+ rebuild_success = False
458
+ if needs_rebuild:
459
+ rebuild_success = await self._try_rebuild_channel(endpoint)
460
+ if not rebuild_success:
461
+ raise exceptions.NoServerAvailableError("Failed to rebuild connection")
462
+
463
+ # 阶段3:更新状态(再次获取锁)
464
+ async with self._async_endpoint_lock:
465
+ # 双重检查:确认在锁外期间状态未被其他协程修改
466
+ if endpoint.is_failed() and endpoint.is_healthy():
467
+ # 状态已恢复
468
+ endpoint.last_failed = 0
469
+ endpoint.in_use = True
470
+ self.metrics.record_endpoint_recovery(str(endpoint))
471
+ elif needs_rebuild and not endpoint.is_failed():
472
+ # 重建成功后的状态
473
+ endpoint.in_use = True
474
+ self.metrics.record_endpoint_recovery(str(endpoint))
475
+
476
+ channel = endpoint.channel
477
+ self.metrics.record_endpoint_usage(str(endpoint))
478
+ return channel
479
+
480
+ async def _check_all_health_parallel(self, timeout=5.0):
481
+ """Parallel health check for all endpoints using TaskGroup.
482
+
483
+ Uses asyncio.TaskGroup for efficient concurrent health checks.
484
+
485
+ :param timeout: Maximum time to wait for all health checks in seconds
486
+ :type timeout: float
487
+ :returns: Dictionary of endpoint label to health status
488
+ :rtype: dict[str, bool]
489
+ """
490
+ health_results = {}
491
+
492
+ async def check_endpoint(label, endpoint):
493
+ """Check health of a single endpoint."""
494
+ try:
495
+ # Use asyncio timeout for individual check
496
+ async with asyncio.timeout(timeout):
497
+ is_healthy = await endpoint.health_check_async()
498
+ health_results[label] = is_healthy
499
+ except (asyncio.TimeoutError, Exception):
500
+ health_results[label] = False
501
+
502
+ # Use TaskGroup for parallel execution (Python 3.11+)
503
+ try:
504
+ async with asyncio.TaskGroup() as tg:
505
+ for label, endpoint in self.endpoints.items():
506
+ tg.create_task(check_endpoint(label, endpoint))
507
+ except* (asyncio.TimeoutError, Exception):
508
+ # Individual task errors are already handled
509
+ pass
510
+
511
+ return health_results
512
+
513
+ async def _get_healthy_endpoints_parallel(self, timeout=3.0):
514
+ """Get list of healthy endpoints using parallel health check.
515
+
516
+ :param timeout: Maximum time for health checks in seconds
517
+ :type timeout: float
518
+ :returns: List of (label, endpoint) tuples for healthy endpoints
519
+ :rtype: list[tuple[str, AsyncEndpoint]]
520
+ """
521
+ health_results = await self._check_all_health_parallel(timeout=timeout)
522
+ return [
523
+ (label, ep)
524
+ for label, ep in self.endpoints.items()
525
+ if health_results.get(label, False)
526
+ ]
527
+
528
+ async def close(self):
529
+ """Call the GRPC channel close semantics."""
530
+ for endpoint in self.endpoints.values():
531
+ await endpoint.close()
532
+
533
+ async def __aenter__(self):
534
+ return self
535
+
536
+ async def __aexit__(self, *args):
537
+ await self.close()
538
+
539
+ def context(self, **kwargs):
540
+ """Create a context manager for tracking request context.
541
+
542
+ This method returns a context manager that sets up request context
543
+ for tracking operations across the etcd client.
544
+
545
+ Example usage:
546
+
547
+ .. code-block:: python
548
+
549
+ async with etcd.context(operation="batch_put", source="worker_1"):
550
+ await etcd.kv.batch_put({f"key_{i}": f"value_{i}" for i in range(100)})
551
+
552
+ # Inside any callback, get the context:
553
+ ctx = get_request_context()
554
+ if ctx:
555
+ print(f"Operation: {ctx.get('operation')}")
556
+
557
+ :param kwargs: Key-value pairs to store in the context
558
+ :returns: A context manager
559
+ """
560
+ from etcd3.async_client_extensions import _request_context
561
+
562
+ class RequestContextManager:
563
+ def __init__(self, client, **context_kwargs):
564
+ self.client = client
565
+ self.context_kwargs = context_kwargs
566
+ self.token = None
567
+
568
+ async def __aenter__(self):
569
+ # Store context with start time and endpoint info
570
+ start_time = asyncio.get_event_loop().time()
571
+ endpoint = self.client.endpoint_in_use
572
+ context_data = {
573
+ "start_time": start_time,
574
+ "endpoint": str(endpoint.netloc) if endpoint else None,
575
+ **self.context_kwargs,
576
+ }
577
+ self.token = _request_context.set(context_data)
578
+ return self
579
+
580
+ async def __aexit__(self, *args):
581
+ if self.token is not None:
582
+ _request_context.reset(self.token)
583
+
584
+ return RequestContextManager(self, **kwargs)
585
+
586
+ @staticmethod
587
+ def get_secure_creds(ca_cert, cert_key=None, cert_cert=None):
588
+ cert_key_file = None
589
+ cert_cert_file = None
590
+
591
+ with open(ca_cert, "rb") as f:
592
+ ca_cert_file = f.read()
593
+
594
+ if cert_key is not None:
595
+ with open(cert_key, "rb") as f:
596
+ cert_key_file = f.read()
597
+
598
+ if cert_cert is not None:
599
+ with open(cert_cert, "rb") as f:
600
+ cert_cert_file = f.read()
601
+
602
+ return grpc.ssl_channel_credentials(ca_cert_file, cert_key_file, cert_cert_file)
603
+
604
+ def _manage_grpc_errors(self, exc):
605
+ code = exc.code()
606
+ if code in FAILED_EP_CODES:
607
+ with self._endpoint_lock:
608
+ endpoint = self.endpoint_in_use
609
+ if endpoint is not None:
610
+ # Mark endpoint as failed, will retry after time_retry
611
+ endpoint.fail()
612
+ # Record endpoint failure
613
+ self.metrics.record_endpoint_failure(str(endpoint))
614
+ exception = EXCEPTIONS_BY_CODE.get(code)
615
+ if exception is None:
616
+ raise exc
617
+ raise exception()
618
+
619
+ def _should_retry(self, exc):
620
+ """Check if the error is retryable."""
621
+ code = getattr(exc, "code", lambda: None)()
622
+ if code in [
623
+ grpc.StatusCode.UNAVAILABLE,
624
+ grpc.StatusCode.DEADLINE_EXCEEDED,
625
+ grpc.StatusCode.INTERNAL,
626
+ grpc.StatusCode.RESOURCE_EXHAUSTED,
627
+ ]:
628
+ return True
629
+ return False
630
+
631
+ async def _retry_with_backoff(self, func, *args, **kwargs):
632
+ """Retry function with exponential backoff."""
633
+ retries = 0
634
+ backoff = self.retry_backoff
635
+
636
+ while retries <= self.max_retries:
637
+ try:
638
+ return await func(*args, **kwargs)
639
+ except grpc.RpcError as exc:
640
+ if not self._should_retry(exc) or retries >= self.max_retries:
641
+ raise
642
+
643
+ retries += 1
644
+ if retries > self.max_retries:
645
+ raise
646
+
647
+ # Record retry
648
+ self.metrics.record_retry(func.__name__, "unknown")
649
+
650
+ # Exponential backoff with jitter
651
+ backoff_time = min(
652
+ backoff * (2 ** (retries - 1)), self.retry_max_backoff
653
+ )
654
+ jitter = random.uniform(0, backoff_time * 0.1)
655
+ await asyncio.sleep(backoff_time + jitter)
656
+ except exceptions.Etcd3Exception as exc:
657
+ # Check if this is a retryable etcd exception
658
+ if not self._should_retry(exc) or retries >= self.max_retries:
659
+ raise
660
+
661
+ retries += 1
662
+ if retries > self.max_retries:
663
+ raise
664
+
665
+ # Record retry
666
+ self.metrics.record_retry(func.__name__, "unknown")
667
+
668
+ # Exponential backoff with jitter
669
+ backoff_time = min(
670
+ backoff * (2 ** (retries - 1)), self.retry_max_backoff
671
+ )
672
+ jitter = random.uniform(0, backoff_time * 0.1)
673
+ await asyncio.sleep(backoff_time + jitter)
674
+
675
+ @staticmethod
676
+ def _handle_errors(payload):
677
+ """Decorator to handle gRPC errors.
678
+
679
+ :param payload: The function to decorate
680
+ :return: Decorated function with error handling
681
+ """
682
+
683
+ @functools.wraps(payload)
684
+ async def handler(self_inner, *args, **kwargs):
685
+ # Only initialize auth if not already done
686
+ if not self_inner._auth_done:
687
+ await self_inner._init_auth()
688
+
689
+ try:
690
+ result = await self_inner._retry_with_backoff(
691
+ payload, self_inner, *args, **kwargs
692
+ )
693
+ return result
694
+ except grpc.RpcError as exc:
695
+ self_inner._manage_grpc_errors(exc)
696
+
697
+ return handler
698
+
699
+ @staticmethod
700
+ def _handle_generator_errors(payload):
701
+ """Decorator to handle gRPC errors in generators.
702
+
703
+ :param payload: The generator function to decorate
704
+ :return: Decorated generator with error handling
705
+ """
706
+
707
+ @functools.wraps(payload)
708
+ async def handler(self_inner, *args, **kwargs):
709
+ # Only initialize auth if not already done
710
+ if not self_inner._auth_done:
711
+ await self_inner._init_auth()
712
+
713
+ try:
714
+ async for item in payload(self_inner, *args, **kwargs):
715
+ yield item
716
+ except grpc.RpcError as exc:
717
+ self_inner._manage_grpc_errors(exc)
718
+
719
+ return handler
720
+
721
+ def _build_get_range_request(self, key, config=None, **kwargs):
722
+ """Build a range request.
723
+
724
+ :param key: The key to get
725
+ :type key: str or bytes
726
+ :param config: Range request configuration
727
+ :type config: RangeRequestConfig
728
+ :param kwargs: Additional keyword arguments for backward compatibility
729
+ :return: RangeRequest instance
730
+ """
731
+ # Use config object if provided, otherwise use kwargs or defaults
732
+ if config is None:
733
+ # Create config from kwargs for backward compatibility
734
+ config = RangeRequestConfig(
735
+ range_end=kwargs.get("range_end"),
736
+ limit=kwargs.get("limit"),
737
+ revision=kwargs.get("revision"),
738
+ sort_order=kwargs.get("sort_order"),
739
+ sort_target=kwargs.get("sort_target", "key"),
740
+ serializable=kwargs.get("serializable", False),
741
+ keys_only=kwargs.get("keys_only", False),
742
+ count_only=kwargs.get("count_only", False),
743
+ min_mod_revision=kwargs.get("min_mod_revision"),
744
+ max_mod_revision=kwargs.get("max_mod_revision"),
745
+ min_create_revision=kwargs.get("min_create_revision"),
746
+ max_create_revision=kwargs.get("max_create_revision"),
747
+ )
748
+
749
+ return RequestBuilder.build_get_range_request(
750
+ key=key,
751
+ range_end=config.range_end,
752
+ limit=config.limit,
753
+ revision=config.revision,
754
+ sort_order=config.sort_order,
755
+ sort_target=config.sort_target,
756
+ serializable=config.serializable,
757
+ keys_only=config.keys_only,
758
+ count_only=config.count_only,
759
+ min_mod_revision=config.min_mod_revision,
760
+ max_mod_revision=config.max_mod_revision,
761
+ min_create_revision=config.min_create_revision,
762
+ max_create_revision=config.max_create_revision,
763
+ )
764
+
765
+ @_handle_errors
766
+ @monitor_request("kv.get_response", is_async=True)
767
+ @log_operation_async("kv.get")
768
+ async def get_response(self, key, **kwargs):
769
+ """Get the value of a key from etcd.
770
+
771
+ :param key: key in etcd to get
772
+ :type key: str or bytes
773
+ :param kwargs: Additional arguments (revision, limit, sort_order, etc.)
774
+ :returns: RangeResponse from etcd
775
+ """
776
+ return await self.kv.get_response(key, **kwargs)
777
+
778
+ async def get(self, key, **kwargs):
779
+ """
780
+ Get the value of a key from etcd.
781
+
782
+ example usage:
783
+
784
+ .. code-block:: python
785
+
786
+ >>> import etcd3
787
+ >>> etcd = etcd3.async_client()
788
+ >>> await etcd.get('/thing/key')
789
+ 'hello world'
790
+
791
+ :param key: key in etcd to get
792
+ :returns: value of key and metadata
793
+ :rtype: bytes, ``KVMetadata``
794
+ """
795
+ return await self.kv.get(key, **kwargs)
796
+
797
+ @_handle_errors
798
+ async def get_prefix_response(self, key_prefix, **kwargs):
799
+ """Get a range of keys with a prefix."""
800
+ return await self.kv.get_prefix_response(key_prefix, **kwargs)
801
+
802
+ async def get_prefix(self, key_prefix, **kwargs):
803
+ """
804
+ Get a range of keys with a prefix.
805
+
806
+ :param key_prefix: first key in range
807
+ :param keys_only: if True, retrieve only the keys, not the values
808
+
809
+ :returns: sequence of (value, metadata) tuples
810
+ """
811
+ async for item in self.kv.get_prefix(key_prefix, **kwargs):
812
+ yield item
813
+
814
+ @_handle_errors
815
+ async def get_range_response(
816
+ self, range_start, range_end, sort_order=None, sort_target="key", **kwargs
817
+ ):
818
+ """Get a range of keys."""
819
+ return await self.kv.get_range_response(
820
+ range_start,
821
+ range_end,
822
+ sort_order=sort_order,
823
+ sort_target=sort_target,
824
+ **kwargs,
825
+ )
826
+
827
+ async def get_range(self, range_start, range_end, **kwargs):
828
+ """
829
+ Get a range of keys.
830
+
831
+ :param range_start: first key in range
832
+ :param range_end: last key in range
833
+ :returns: sequence of (value, metadata) tuples
834
+ """
835
+ async for item in self.kv.get_range(range_start, range_end, **kwargs):
836
+ yield item
837
+
838
+ @_handle_errors
839
+ async def get_all_response(
840
+ self, sort_order=None, sort_target="key", keys_only=False
841
+ ):
842
+ """Get all keys currently stored in etcd."""
843
+ return await self.kv.get_all_response(
844
+ sort_order=sort_order, sort_target=sort_target, keys_only=keys_only
845
+ )
846
+
847
+ async def get_all(self, **kwargs):
848
+ """
849
+ Get all keys currently stored in etcd.
850
+
851
+ :param keys_only: if True, retrieve only the keys, not the values
852
+ :returns: sequence of (value, metadata) tuples
853
+ """
854
+ async for item in self.kv.get_all(**kwargs):
855
+ yield item
856
+
857
+ @_handle_errors
858
+ @monitor_request("kv.put", is_async=True)
859
+ @log_operation_async("kv.put")
860
+ async def put(
861
+ self,
862
+ key,
863
+ value=None,
864
+ lease=None,
865
+ prev_kv=False,
866
+ ignore_value=False,
867
+ ignore_lease=False,
868
+ ):
869
+ """
870
+ Save a value to etcd.
871
+
872
+ Example usage:
873
+
874
+ .. code-block:: python
875
+
876
+ >>> import etcd3
877
+ >>> etcd = etcd3.async_client()
878
+ >>> await etcd.put('/thing/key', 'hello world')
879
+
880
+ :param key: key in etcd to set
881
+ :param value: value to set key to
882
+ :type value: bytes
883
+ :param lease: Lease to associate with this key.
884
+ :type lease: either :class:`.Lease`, or int (ID of lease)
885
+ :param prev_kv: return the previous key-value pair
886
+ :type prev_kv: bool
887
+ :param ignore_value: if True, update the key using the current value
888
+ :type ignore_value: bool
889
+ :param ignore_lease: if True, update the key using the current lease
890
+ :type ignore_lease: bool
891
+ :returns: a response containing a header and the prev_kv
892
+ :rtype: :class:`.rpc_pb2.PutResponse`
893
+ """
894
+ return await self.kv.put(
895
+ key,
896
+ value=value,
897
+ lease=lease,
898
+ prev_kv=prev_kv,
899
+ ignore_value=ignore_value,
900
+ ignore_lease=ignore_lease,
901
+ )
902
+
903
+ @_handle_errors
904
+ async def put_if_not_exists(self, key, value, lease=None):
905
+ """
906
+ Atomically puts a value only if the key previously had no value.
907
+
908
+ This is the etcdv3 equivalent to setting a key with the etcdv2
909
+ parameter prevExist=false.
910
+
911
+ :param key: key in etcd to put
912
+ :param value: value to be written to key
913
+ :type value: bytes
914
+ :param lease: Lease to associate with this key.
915
+ :type lease: either :class:`.Lease`, or int (ID of lease)
916
+ :returns: state of transaction, ``True`` if the put was successful,
917
+ ``False`` otherwise
918
+ :rtype: bool
919
+ """
920
+ return await self.kv.put_if_not_exists(key, value, lease=lease)
921
+
922
+ @_handle_errors
923
+ async def replace(self, key, initial_value, new_value):
924
+ """
925
+ Atomically replace the value of a key with a new value.
926
+
927
+ This compares the current value of a key, then replaces it with a new
928
+ value if it is equal to a specified value. This operation takes place
929
+ in a transaction.
930
+
931
+ :param key: key in etcd to replace
932
+ :param initial_value: old value to replace
933
+ :type initial_value: bytes
934
+ :param new_value: new value of the key
935
+ :type new_value: bytes
936
+ :returns: status of transaction, ``True`` if the replace was
937
+ successful, ``False`` otherwise
938
+ :rtype: bool
939
+ """
940
+ return await self.kv.replace(key, initial_value, new_value)
941
+
942
+ def _ops_to_requests(self, ops):
943
+ """
944
+ Return a list of grpc requests.
945
+
946
+ Returns list from an input list of etcd3.transactions.{Put, Get,
947
+ Delete, Txn} objects.
948
+ """
949
+ request_ops = []
950
+ for op in ops:
951
+ if isinstance(op, transactions.Put):
952
+ put_request = etcdrpc.PutRequest()
953
+ put_request.key = utils.to_bytes(op.key)
954
+ put_request.value = utils.to_bytes(op.value)
955
+ put_request.lease = utils.lease_to_id(getattr(op, "lease", None))
956
+ put_request.prev_kv = getattr(op, "prev_kv", False)
957
+ request_op = etcdrpc.RequestOp(request_put=put_request)
958
+ request_ops.append(request_op)
959
+
960
+ elif isinstance(op, transactions.Get):
961
+ range_request = self._build_get_range_request(op.key, op.range_end)
962
+ request_op = etcdrpc.RequestOp(request_range=range_request)
963
+ request_ops.append(request_op)
964
+
965
+ elif isinstance(op, transactions.Delete):
966
+ delete_request = etcdrpc.DeleteRangeRequest()
967
+ delete_request.key = utils.to_bytes(op.key)
968
+ delete_request.prev_kv = op.prev_kv
969
+ if op.range_end is not None:
970
+ delete_request.range_end = utils.to_bytes(op.range_end)
971
+ request_op = etcdrpc.RequestOp(request_delete_range=delete_request)
972
+ request_ops.append(request_op)
973
+
974
+ elif isinstance(op, transactions.Txn):
975
+ compare = [c.build_message() for c in op.compare]
976
+ success_ops = self._ops_to_requests(op.success)
977
+ failure_ops = self._ops_to_requests(op.failure)
978
+ txn_request = etcdrpc.TxnRequest(
979
+ compare=compare, success=success_ops, failure=failure_ops
980
+ )
981
+ request_op = etcdrpc.RequestOp(request_txn=txn_request)
982
+ request_ops.append(request_op)
983
+
984
+ else:
985
+ raise ValueError(f"Unknown request type: {op.__class__}")
986
+ return request_ops
987
+
988
+ @_handle_errors
989
+ @monitor_request("kv.transaction", is_async=True)
990
+ @log_operation_async("kv.transaction")
991
+ async def transaction(self, compare, success=None, failure=None):
992
+ """
993
+ Perform a transaction.
994
+
995
+ Example usage:
996
+
997
+ .. code-block:: python
998
+
999
+ await etcd.transaction(
1000
+ compare=[
1001
+ etcd.transactions.value('/doot/testing') == 'doot',
1002
+ etcd.transactions.version('/doot/testing') > 0,
1003
+ ],
1004
+ success=[
1005
+ etcd.transactions.put('/doot/testing', 'success'),
1006
+ ],
1007
+ failure=[
1008
+ etcd.transactions.put('/doot/testing', 'failure'),
1009
+ ]
1010
+ )
1011
+
1012
+ :param compare: A list of comparisons to make
1013
+ :param success: A list of operations to perform if all the comparisons
1014
+ are true
1015
+ :param failure: A list of operations to perform if any of the
1016
+ comparisons are false
1017
+ :return: A tuple of (operation status, responses)
1018
+ """
1019
+ compare = [c.build_message() for c in compare]
1020
+
1021
+ success_ops = self._ops_to_requests(success or [])
1022
+ failure_ops = self._ops_to_requests(failure or [])
1023
+
1024
+ transaction_request = etcdrpc.TxnRequest(
1025
+ compare=compare, success=success_ops, failure=failure_ops
1026
+ )
1027
+
1028
+ # Create KV stub with auto-reconnection
1029
+ channel = await self._get_channel()
1030
+ kv_stub = etcdrpc.KVStub(channel)
1031
+ txn_response = await kv_stub.Txn(
1032
+ transaction_request,
1033
+ timeout=self.timeout,
1034
+ credentials=self.call_credentials,
1035
+ metadata=self.metadata,
1036
+ )
1037
+
1038
+ responses = []
1039
+ for response in txn_response.responses:
1040
+ response_type = response.WhichOneof("response")
1041
+ if response_type == "response_range":
1042
+ responses.append(
1043
+ [
1044
+ (kv.value, KVMetadata(kv, txn_response.header))
1045
+ for kv in response.response_range.kvs
1046
+ ]
1047
+ )
1048
+ elif response_type == "response_put":
1049
+ responses.append(response.response_put)
1050
+ elif response_type == "response_delete_range":
1051
+ responses.append(response.response_delete_range)
1052
+ elif response_type == "response_txn":
1053
+ responses.append(response.response_txn)
1054
+ else:
1055
+ # Unknown response type - raise error for visibility
1056
+ raise exceptions.InternalServerError(
1057
+ f"Unknown transaction response type: {response_type}"
1058
+ )
1059
+
1060
+ return txn_response.succeeded, responses
1061
+
1062
+ @_handle_errors
1063
+ @monitor_request("kv.delete", is_async=True)
1064
+ @log_operation_async("kv.delete")
1065
+ async def delete(self, key, prev_kv=False, return_response=False, range_end=None):
1066
+ """
1067
+ Delete a key or range of keys in etcd.
1068
+
1069
+ :param key: key in etcd to delete
1070
+ :type key: str or bytes
1071
+ :param prev_kv: return the deleted key-value pair
1072
+ :type prev_kv: bool
1073
+ :param return_response: return the full response
1074
+ :type return_response: bool
1075
+ :param range_end: End of the key range (for range deletes)
1076
+ :type range_end: str or bytes, optional
1077
+ :returns: True if the key has been deleted when
1078
+ ``return_response`` is False and a response containing
1079
+ a header, the number of deleted keys and prev_kvs when
1080
+ ``return_response`` is True
1081
+ """
1082
+ return await self.kv.delete(
1083
+ key, prev_kv=prev_kv, return_response=return_response, range_end=range_end
1084
+ )
1085
+
1086
+ @_handle_errors
1087
+ @monitor_request("kv.delete_prefix", is_async=True)
1088
+ @log_operation_async("kv.delete_prefix")
1089
+ async def delete_prefix(self, prefix):
1090
+ """Delete a range of keys with a prefix in etcd."""
1091
+ return await self.kv.delete_prefix(prefix)
1092
+
1093
+ @_handle_errors
1094
+ @monitor_request("lease.create", is_async=True)
1095
+ @log_operation_async("lease.create")
1096
+ async def lease(self, ttl, lease_id=None):
1097
+ """
1098
+ Create a new lease.
1099
+
1100
+ All keys attached to this lease will be expired and deleted if the
1101
+ lease expires. A lease can be sent keep alive messages to refresh the
1102
+ ttl.
1103
+
1104
+ :param ttl: Requested time to live
1105
+ :param lease_id: Requested ID for the lease
1106
+
1107
+ :returns: new lease
1108
+ :rtype: :class:`.AsyncLease`
1109
+ """
1110
+ return await self.lease_ops.lease(ttl, lease_id=lease_id)
1111
+
1112
+ @_handle_errors
1113
+ @monitor_request("lease.revoke", is_async=True)
1114
+ @log_operation_async("lease.revoke")
1115
+ async def revoke_lease(self, lease_id):
1116
+ """
1117
+ Revoke a lease.
1118
+
1119
+ :param lease_id: ID of the lease to revoke.
1120
+ """
1121
+ return await self.lease_ops.revoke_lease(lease_id)
1122
+
1123
+ @_handle_generator_errors
1124
+ async def refresh_lease(self, lease_id):
1125
+ """
1126
+ Refresh a lease.
1127
+
1128
+ :param lease_id: ID of the lease to refresh.
1129
+ :returns: Async generator of keep alive responses
1130
+ """
1131
+ async for response in self.lease_ops.refresh_lease(lease_id):
1132
+ yield response
1133
+
1134
+ @_handle_errors
1135
+ @monitor_request("lease.get_info", is_async=True)
1136
+ @log_operation_async("lease.get_info")
1137
+ async def get_lease_info(self, lease_id):
1138
+ """
1139
+ Get lease information.
1140
+
1141
+ :param lease_id: ID of the lease to get information about.
1142
+ :returns: Lease information
1143
+ """
1144
+ return await self.lease_ops.get_lease_info(lease_id)
1145
+
1146
+ @_handle_errors
1147
+ async def lock(self, name, ttl=60):
1148
+ """
1149
+ Create a new async lock.
1150
+
1151
+ :param name: name of the lock
1152
+ :type name: string or bytes
1153
+ :param ttl: length of time for the lock to live for in seconds. The
1154
+ lock will be released after this time elapses, unless
1155
+ refreshed
1156
+ :type ttl: int
1157
+ :returns: new async lock
1158
+ :rtype: :class:`.AsyncLock`
1159
+ """
1160
+ return await self.lock_ops.lock(name, ttl=ttl)
1161
+
1162
+ async def add_member(self, urls):
1163
+ """
1164
+ Add a member into the cluster.
1165
+
1166
+ :returns: new member
1167
+ :rtype: :class:`.Member`
1168
+ """
1169
+ return await self.cluster_ops.add_member(urls)
1170
+
1171
+ async def remove_member(self, member_id):
1172
+ """
1173
+ Remove an existing member from the cluster.
1174
+
1175
+ :param member_id: ID of the member to remove
1176
+ """
1177
+ return await self.cluster_ops.remove_member(member_id)
1178
+
1179
+ async def update_member(self, member_id, peer_urls):
1180
+ """
1181
+ Update the configuration of an existing member in the cluster.
1182
+
1183
+ :param member_id: ID of the member to update
1184
+ :param peer_urls: new list of peer urls the member will use to
1185
+ communicate with the cluster
1186
+ """
1187
+ return await self.cluster_ops.update_member(member_id, peer_urls)
1188
+
1189
+ async def members(self):
1190
+ """
1191
+ List of all members associated with the cluster.
1192
+
1193
+ :type: sequence of :class:`.Member`
1194
+
1195
+ """
1196
+ async for member in self.cluster_ops.members():
1197
+ yield member
1198
+
1199
+ async def status(self):
1200
+ """Get the status of the responding member."""
1201
+ return await self.maintenance_ops.status()
1202
+
1203
+ @_handle_errors
1204
+ @monitor_request("maintenance.compact", is_async=True)
1205
+ @log_operation_async("maintenance.compact")
1206
+ async def compact(self, revision, physical=False):
1207
+ """
1208
+ Compact the event history in etcd up to a given revision.
1209
+
1210
+ All superseded keys with a revision less than the compaction revision
1211
+ will be removed.
1212
+
1213
+ :param revision: revision for the compaction operation
1214
+ :param physical: if set to True, the request will wait until the
1215
+ compaction is physically applied to the local database
1216
+ such that compacted entries are totally removed from
1217
+ the backend database
1218
+ """
1219
+ return await self.maintenance_ops.compact(revision, physical=physical)
1220
+
1221
+ async def defragment(self):
1222
+ """Defragment a member's backend database to recover storage space."""
1223
+ return await self.maintenance_ops.defragment()
1224
+
1225
+ async def hash(self):
1226
+ """
1227
+ Return the hash of the local KV state.
1228
+
1229
+ :returns: kv state hash
1230
+ :rtype: int
1231
+ """
1232
+ return await self.maintenance_ops.hash()
1233
+
1234
+ async def create_alarm(self, member_id=0):
1235
+ """Create an alarm.
1236
+
1237
+ If no member id is given, the alarm is activated for all the
1238
+ members of the cluster. Only the `no space` alarm can be raised.
1239
+
1240
+ :param member_id: The cluster member id to create an alarm to.
1241
+ If 0, the alarm is created for all the members
1242
+ of the cluster.
1243
+ :returns: list of :class:`.Alarm`
1244
+ """
1245
+ return await self.maintenance_ops.create_alarm(member_id)
1246
+
1247
+ @_handle_generator_errors
1248
+ async def list_alarms(self, member_id=0, alarm_type="none"):
1249
+ """List the activated alarms.
1250
+
1251
+ :param member_id:
1252
+ :param alarm_type: The cluster member id to create an alarm to.
1253
+ If 0, the alarm is created for all the members
1254
+ of the cluster.
1255
+ :returns: sequence of :class:`.Alarm`
1256
+ """
1257
+ async for alarm in self.maintenance_ops.list_alarms(member_id, alarm_type):
1258
+ yield alarm
1259
+
1260
+ async def disarm_alarm(self, member_id=0):
1261
+ """Cancel an alarm.
1262
+
1263
+ :param member_id: The cluster member id to cancel an alarm.
1264
+ If 0, the alarm is canceled for all the members
1265
+ of the cluster.
1266
+ :returns: List of :class:`.Alarm`
1267
+ """
1268
+ return await self.maintenance_ops.disarm_alarm(member_id)
1269
+
1270
+ async def snapshot(self, file_obj):
1271
+ """Take a snapshot of the database.
1272
+
1273
+ :param file_obj: A file-like object to write the database contents in.
1274
+ """
1275
+ return await self.maintenance_ops.snapshot(file_obj)
1276
+
1277
+ @monitor_request("watch.add_callback", is_async=True)
1278
+ @log_operation_async("watch.add_callback")
1279
+ async def add_watch_callback(self, *args, **kwargs):
1280
+ """
1281
+ Watch a key or range of keys and call a callback on every response.
1282
+
1283
+ If timeout was declared during the client initialization and
1284
+ the watch cannot be created during that time the method raises
1285
+ a ``WatchTimedOut`` exception.
1286
+
1287
+ :param key: key to watch
1288
+ :param callback: callback function
1289
+
1290
+ :returns: watch_id. Later it could be used for cancelling watch.
1291
+ """
1292
+ return await self.watch_ops.add_watch_callback(*args, **kwargs)
1293
+
1294
+ async def add_watch_prefix_callback(self, key_prefix, callback, **kwargs):
1295
+ """
1296
+ Watch a prefix and call a callback on every response.
1297
+
1298
+ If timeout was declared during the client initialization and
1299
+ the watch cannot be created during that time the method raises
1300
+ a ``WatchTimedOut`` exception.
1301
+
1302
+ :param key_prefix: prefix to watch
1303
+ :param callback: callback function
1304
+
1305
+ :returns: watch_id. Later it could be used for cancelling watch.
1306
+ """
1307
+ return await self.watch_ops.add_watch_prefix_callback(
1308
+ key_prefix, callback, **kwargs
1309
+ )
1310
+
1311
+ async def watch_response(self, key, **kwargs):
1312
+ """
1313
+ Watch a key.
1314
+
1315
+ Example usage:
1316
+
1317
+ .. code-block:: python
1318
+
1319
+ responses_iterator, cancel = await etcd.watch_response('/doot/key')
1320
+ async for response in responses_iterator:
1321
+ print(response)
1322
+
1323
+ :param key: key to watch
1324
+
1325
+ :returns: tuple of ``responses_iterator`` and ``cancel``.
1326
+ Use ``responses_iterator`` to get the watch responses,
1327
+ each of which contains a header and a list of events.
1328
+ Use ``cancel`` to cancel the watch request.
1329
+ """
1330
+ return await self.watch_ops.watch_response(key, **kwargs)
1331
+
1332
+ async def watch(self, key, **kwargs):
1333
+ """
1334
+ Watch a key.
1335
+
1336
+ Example usage:
1337
+
1338
+ .. code-block:: python
1339
+
1340
+ events_iterator, cancel = await etcd.watch('/doot/key')
1341
+ async for event in events_iterator:
1342
+ print(event)
1343
+
1344
+ :param key: key to watch
1345
+
1346
+ :returns: tuple of ``events_iterator`` and ``cancel``.
1347
+ Use ``events_iterator`` to get the events of key changes
1348
+ and ``cancel`` to cancel the watch request.
1349
+ """
1350
+ return await self.watch_ops.watch(key, **kwargs)
1351
+
1352
+ async def watch_prefix_response(self, key_prefix, **kwargs):
1353
+ """
1354
+ Watch a range of keys with a prefix.
1355
+
1356
+ :param key_prefix: prefix to watch
1357
+
1358
+ :returns: tuple of ``responses_iterator`` and ``cancel``.
1359
+ """
1360
+ return await self.watch_ops.watch_prefix_response(key_prefix, **kwargs)
1361
+
1362
+ async def watch_prefix(self, key_prefix, **kwargs):
1363
+ """
1364
+ Watch a range of keys with a prefix.
1365
+
1366
+ :param key_prefix: prefix to watch
1367
+
1368
+ :returns: tuple of ``events_iterator`` and ``cancel``.
1369
+ """
1370
+ return await self.watch_ops.watch_prefix(key_prefix, **kwargs)
1371
+
1372
+ async def watch_once_response(self, key, timeout=None, **kwargs):
1373
+ """
1374
+ Watch a key and stop after the first response.
1375
+
1376
+ If the timeout was specified and response didn't arrive method
1377
+ will raise ``WatchTimedOut`` exception.
1378
+
1379
+ :param key: key to watch
1380
+ :param timeout: (optional) timeout in seconds.
1381
+
1382
+ :returns: ``WatchResponse``
1383
+ """
1384
+ return await self.watch_ops.watch_once_response(key, timeout=timeout, **kwargs)
1385
+
1386
+ async def watch_once(self, key, timeout=None, **kwargs):
1387
+ """
1388
+ Watch a key and stop after the first event.
1389
+
1390
+ If the timeout was specified and event didn't arrive method
1391
+ will raise ``WatchTimedOut`` exception.
1392
+
1393
+ :param key: key to watch
1394
+ :param timeout: (optional) timeout in seconds.
1395
+
1396
+ :returns: ``Event``
1397
+ """
1398
+ return await self.watch_ops.watch_once(key, timeout=timeout, **kwargs)
1399
+
1400
+ async def watch_prefix_once_response(self, key_prefix, timeout=None, **kwargs):
1401
+ """
1402
+ Watch a range of keys with a prefix and stop after the first response.
1403
+
1404
+ If the timeout was specified and response didn't arrive method
1405
+ will raise ``WatchTimedOut`` exception.
1406
+ """
1407
+ return await self.watch_ops.watch_prefix_once_response(
1408
+ key_prefix, timeout=timeout, **kwargs
1409
+ )
1410
+
1411
+ async def watch_prefix_once(self, key_prefix, timeout=None, **kwargs):
1412
+ """
1413
+ Watch a range of keys with a prefix and stop after the first event.
1414
+
1415
+ If the timeout was specified and event didn't arrive method
1416
+ will raise ``WatchTimedOut`` exception.
1417
+ """
1418
+ return await self.watch_ops.watch_prefix_once(
1419
+ key_prefix, timeout=timeout, **kwargs
1420
+ )
1421
+
1422
+ @monitor_request("watch.cancel", is_async=True)
1423
+ @log_operation_async("watch.cancel")
1424
+ async def cancel_watch(self, watch_id):
1425
+ """
1426
+ Stop watching a key or range of keys.
1427
+
1428
+ :param watch_id: watch_id returned by ``add_watch_callback`` method
1429
+ """
1430
+ return await self.watch_ops.cancel_watch(watch_id)
1431
+
1432
+
1433
+ async def async_client(
1434
+ config=None,
1435
+ host="localhost",
1436
+ port=2379,
1437
+ ca_cert=None,
1438
+ cert_key=None,
1439
+ cert_cert=None,
1440
+ timeout=None,
1441
+ user=None,
1442
+ password=None,
1443
+ grpc_options=None,
1444
+ ):
1445
+ """Return an instance of an AsyncEtcd3Client.
1446
+
1447
+ :param config: AsyncClientConfig object with client configuration
1448
+ :param host: etcd host (ignored if config is provided)
1449
+ :param port: etcd port (ignored if config is provided)
1450
+ :param ca_cert: CA certificate path (ignored if config is provided)
1451
+ :param cert_key: client certificate key path (ignored if config is provided)
1452
+ :param cert_cert: client certificate path (ignored if config is provided)
1453
+ :param timeout: request timeout in seconds (ignored if config is provided)
1454
+ :param user: username for authentication (ignored if config is provided)
1455
+ :param password: password for authentication (ignored if config is provided)
1456
+ :param grpc_options: additional gRPC options (ignored if config is provided)
1457
+ :return: AsyncEtcd3Client instance
1458
+ """
1459
+ # Auto-initialize observability
1460
+ from etcd3.observability import auto_init
1461
+
1462
+ auto_init()
1463
+
1464
+ # Use config object if provided, otherwise use individual parameters
1465
+ if config is not None:
1466
+ host = config.host
1467
+ port = config.port
1468
+ ca_cert = config.ca_cert
1469
+ cert_key = config.cert_key
1470
+ cert_cert = config.cert_cert
1471
+ timeout = config.timeout
1472
+ user = config.user
1473
+ password = config.password
1474
+ grpc_options = config.grpc_options
1475
+
1476
+ credentials = None
1477
+ secure = True
1478
+
1479
+ if ca_cert is not None:
1480
+ credentials = AsyncMultiEndpointEtcd3Client.get_secure_creds(
1481
+ ca_cert, cert_key, cert_cert
1482
+ )
1483
+ else:
1484
+ secure = False
1485
+
1486
+ endpoint = AsyncEndpoint(
1487
+ host=host, port=port, secure=secure, creds=credentials, opts=grpc_options
1488
+ )
1489
+ return AsyncMultiEndpointEtcd3Client(
1490
+ endpoints=[endpoint], timeout=timeout, user=user, password=password
1491
+ )
1492
+
1493
+
1494
+ # Create alias for backward compatibility
1495
+ AsyncEtcd3Client = AsyncMultiEndpointEtcd3Client