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/client.py ADDED
@@ -0,0 +1,2021 @@
1
+ """Synchronous etcd3 client implementation.
2
+
3
+ This module provides synchronous client classes for interacting with etcd v3 API.
4
+ """
5
+
6
+ import random
7
+ import threading
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from typing import Optional, Literal, Any
11
+
12
+ import grpc
13
+ import etcdrpc
14
+ import queue
15
+
16
+
17
+ import etcd3.exceptions as exceptions
18
+ import etcd3.leases as leases
19
+ import etcd3.locks as locks
20
+ import etcd3.members
21
+ import etcd3.transactions as transactions
22
+ import etcd3.utils as utils
23
+ import etcd3.watch as watch
24
+ from etcd3.log import log_operation
25
+ from etcd3.exporters.prometheus import get_metrics_interface, monitor_request
26
+
27
+ from etcd3.shared import (
28
+ Transactions,
29
+ KVMetadata,
30
+ Status,
31
+ Alarm,
32
+ EtcdTokenCallCredentials,
33
+ _EXCEPTIONS_BY_CODE,
34
+ _FAILED_EP_CODES,
35
+ get_secure_creds,
36
+ _make_sync_handle_errors_with_tracing,
37
+ _make_sync_handle_generator_errors_with_tracing,
38
+ RequestBuilder,
39
+ )
40
+ from etcd3.endpoint import BaseEndpoint
41
+
42
+
43
+ def _manage_client_errors(self, exc):
44
+ """Error handler for sync operations that routes to client's error handler."""
45
+ self._manage_grpc_errors(exc)
46
+
47
+
48
+ # Create trace-aware error handlers for sync operations
49
+ _sync_handle_errors = _make_sync_handle_errors_with_tracing(
50
+ _manage_client_errors, "etcd3.operation"
51
+ )
52
+ _sync_handle_generator_errors = _make_sync_handle_generator_errors_with_tracing(
53
+ _manage_client_errors, "etcd3.operation"
54
+ )
55
+ # Error handler for lease keepalive that propagates errors to allow detection of connection loss
56
+ _sync_handle_keepalive_errors = _make_sync_handle_generator_errors_with_tracing(
57
+ _manage_client_errors, "etcd3.keepalive", propagate_errors=True
58
+ )
59
+
60
+
61
+ @dataclass(kw_only=True)
62
+ class RangeRequestConfig:
63
+ """Configuration for range request.
64
+
65
+ :param range_end: End of the key range
66
+ :type range_end: str or bytes
67
+ :param limit: Maximum number of keys to return
68
+ :type limit: int
69
+ :param revision: Revision to get
70
+ :type revision: int
71
+ :param sort_order: Sort order ('ascend' or 'descend')
72
+ :type sort_order: str
73
+ :param sort_target: Sort target ('key', 'version', 'create', 'mod')
74
+ :type sort_target: str
75
+ :param serializable: Whether to use serializable read
76
+ :type serializable: bool
77
+ :param keys_only: Whether to return only keys
78
+ :type keys_only: bool
79
+ :param count_only: Whether to return only count
80
+ :type count_only: bool
81
+ :param min_mod_revision: Minimum modification revision
82
+ :type min_mod_revision: int
83
+ :param max_mod_revision: Maximum modification revision
84
+ :type max_mod_revision: int
85
+ :param min_create_revision: Minimum creation revision
86
+ :type min_create_revision: int
87
+ :param max_create_revision: Maximum creation revision
88
+ :type max_create_revision: int
89
+ """
90
+
91
+ range_end: Optional[bytes | str] = None
92
+ limit: Optional[int] = None
93
+ revision: Optional[int] = None
94
+ sort_order: Optional[Literal["ascend", "descend"]] = None
95
+ sort_target: Literal["key", "version", "create", "mod"] = "key"
96
+ serializable: bool = False
97
+ keys_only: bool = False
98
+ count_only: bool = False
99
+ min_mod_revision: Optional[int] = None
100
+ max_mod_revision: Optional[int] = None
101
+ min_create_revision: Optional[int] = None
102
+ max_create_revision: Optional[int] = None
103
+
104
+ def __post_init__(self):
105
+ if self.limit is not None and self.limit < 1:
106
+ raise ValueError("limit must be a positive integer")
107
+ if self.revision is not None and self.revision < 0:
108
+ raise ValueError("revision must be a non-negative integer")
109
+ if self.min_mod_revision is not None and self.min_mod_revision < 0:
110
+ raise ValueError("min_mod_revision must be a non-negative integer")
111
+ if self.max_mod_revision is not None and self.max_mod_revision < 0:
112
+ raise ValueError("max_mod_revision must be a non-negative integer")
113
+ if self.min_create_revision is not None and self.min_create_revision < 0:
114
+ raise ValueError("min_create_revision must be a non-negative integer")
115
+ if self.max_create_revision is not None and self.max_create_revision < 0:
116
+ raise ValueError("max_create_revision must be a non-negative integer")
117
+
118
+
119
+ @dataclass(kw_only=True)
120
+ class ClientConfig:
121
+ """Configuration for single endpoint client creation.
122
+
123
+ :param host: etcd host
124
+ :type host: str
125
+ :param port: etcd port
126
+ :type port: int
127
+ :param ca_cert: CA certificate path
128
+ :type ca_cert: str
129
+ :param cert_key: client certificate key path
130
+ :type cert_key: str
131
+ :param cert_cert: client certificate path
132
+ :type cert_cert: str
133
+ :param timeout: request timeout in seconds
134
+ :type timeout: int or float
135
+ :param user: username for authentication
136
+ :type user: str
137
+ :param password: password for authentication
138
+ :type password: str
139
+ :param grpc_options: additional gRPC options
140
+ :type grpc_options: dict[str, Any]
141
+ """
142
+
143
+ host: str = "localhost"
144
+ port: int = 2379
145
+ ca_cert: Optional[str] = None
146
+ cert_key: Optional[str] = None
147
+ cert_cert: Optional[str] = None
148
+ timeout: Optional[float] = None
149
+ user: Optional[str] = None
150
+ password: Optional[str] = None
151
+ grpc_options: dict[str, Any] = field(default_factory=dict)
152
+
153
+ def __post_init__(self):
154
+ if self.port < 1 or self.port > 65535:
155
+ raise ValueError("port must be between 1 and 65535")
156
+ if self.timeout is not None and self.timeout <= 0:
157
+ raise ValueError("timeout must be a positive number")
158
+
159
+
160
+ @dataclass(kw_only=True)
161
+ class MultiEndpointConfig:
162
+ """Configuration for MultiEndpointEtcd3Client creation.
163
+
164
+ :param endpoints: Endpoints to use
165
+ :type endpoints: Iterable[Endpoint]
166
+ :param timeout: Timeout for all RPC in seconds
167
+ :type timeout: float, optional
168
+ :param user: Username for authentication
169
+ :type user: str, optional
170
+ :param password: Password for authentication
171
+ :type password: str, optional
172
+ :param failover: Failover between endpoints, default False
173
+ :type failover: bool
174
+ :param max_retries: Maximum number of retries for transient errors
175
+ :type max_retries: int
176
+ :param retry_backoff: Initial backoff time in seconds for retries
177
+ :type retry_backoff: float
178
+ :param retry_max_backoff: Maximum backoff time in seconds for retries
179
+ :type retry_max_backoff: float
180
+ :param load_balancer: Load balancing strategy
181
+ :type load_balancer: LoadBalancerStrategy
182
+ :param health_check_interval: Health check interval in seconds
183
+ :type health_check_interval: float
184
+ """
185
+
186
+ endpoints: Optional[list] = None
187
+ timeout: Optional[float] = None
188
+ user: Optional[str] = None
189
+ password: Optional[str] = None
190
+ failover: bool = False
191
+ max_retries: int = 3
192
+ retry_backoff: float = 0.1
193
+ retry_max_backoff: float = 10.0
194
+ load_balancer: utils.LoadBalancerStrategy = utils.LoadBalancerStrategy.PRIORITY
195
+ health_check_interval: float = 30.0
196
+
197
+ def __post_init__(self):
198
+ if self.max_retries < 0:
199
+ raise ValueError("max_retries must be a non-negative integer")
200
+ if self.retry_backoff < 0:
201
+ raise ValueError("retry_backoff must be a non-negative number")
202
+ if self.retry_max_backoff < 0:
203
+ raise ValueError("retry_max_backoff must be a non-negative number")
204
+ if self.retry_backoff > self.retry_max_backoff:
205
+ raise ValueError("retry_backoff must not exceed retry_max_backoff")
206
+ if self.health_check_interval <= 0:
207
+ raise ValueError("health_check_interval must be a positive number")
208
+
209
+
210
+ class Endpoint(BaseEndpoint):
211
+ """Represents an etcd cluster endpoint.
212
+
213
+ :param str host: Endpoint host
214
+ :param int port: Endpoint port
215
+ :param bool secure: Use secure channel, default True
216
+ :param creds: Credentials to use for secure channel, required if
217
+ secure=True
218
+ :type creds: grpc.ChannelCredentials, optional
219
+ :param time_retry: Seconds to wait before retrying this endpoint after
220
+ failure, default 300.0
221
+ :type time_retry: int or float
222
+ :param opts: Additional gRPC options
223
+ :type opts: dict, optional
224
+ """
225
+
226
+ def __init__(
227
+ self, host, port, secure=True, creds=None, time_retry=300.0, opts=None
228
+ ):
229
+ super().__init__(host, port, secure, creds, time_retry, opts)
230
+ self.channel = self._mkchannel(opts)
231
+
232
+ def close(self):
233
+ """Close the gRPC channel."""
234
+ try:
235
+ self.channel.close()
236
+ except Exception:
237
+ pass # Ignore close errors
238
+
239
+ def rebuild_channel(self):
240
+ """Rebuild the gRPC channel.
241
+
242
+ This method closes the existing channel and creates a new one,
243
+ which is useful when the connection has become stale (e.g., after
244
+ etcd server restart).
245
+ """
246
+ try:
247
+ self.channel.close()
248
+ except Exception:
249
+ pass # Ignore close errors
250
+
251
+ self.channel = self._mkchannel(self.opts)
252
+
253
+ def _mkchannel(self, opts):
254
+ if self.secure:
255
+ return grpc.secure_channel(self.netloc, self.credentials, options=opts)
256
+ else:
257
+ return grpc.insecure_channel(self.netloc, options=opts)
258
+
259
+
260
+ class MultiEndpointEtcd3Client(object):
261
+ """
262
+ etcd v3 API client with multiple endpoints.
263
+
264
+ When failover is enabled, requests still will not be auto-retried.
265
+ Instead, the application may retry the request, and the ``Etcd3Client``
266
+ will then attempt to send it to a different endpoint that has not recently
267
+ failed. If all configured endpoints have failed and are not ready to be
268
+ retried, an ``exceptions.NoServerAvailableError`` will be raised.
269
+
270
+ :param config: MultiEndpointConfig object with client configuration
271
+ :param endpoints: Endpoints to use in lieu of host and port
272
+ :type endpoints: Iterable(Endpoint), optional
273
+ :param timeout: Timeout for all RPC in seconds
274
+ :type timeout: int or float, optional
275
+ :param user: Username for authentication
276
+ :type user: str, optional
277
+ :param password: Password for authentication
278
+ :type password: str, optional
279
+ :param bool failover: Failover between endpoints, default False
280
+ :param int max_retries: Maximum number of retries for transient errors
281
+ :param float retry_backoff: Initial backoff time in seconds for retries
282
+ :param float retry_max_backoff: Maximum backoff time in seconds for retries
283
+ """
284
+
285
+ def __init__(
286
+ self,
287
+ config=None,
288
+ endpoints=None,
289
+ timeout=None,
290
+ user=None,
291
+ password=None,
292
+ failover=False,
293
+ max_retries=3,
294
+ retry_backoff=0.1,
295
+ retry_max_backoff=10.0,
296
+ load_balancer=None,
297
+ health_check_interval=30.0,
298
+ ):
299
+ if config is not None:
300
+ endpoints = config.endpoints
301
+ timeout = config.timeout
302
+ user = config.user
303
+ password = config.password
304
+ failover = config.failover
305
+ max_retries = config.max_retries
306
+ retry_backoff = config.retry_backoff
307
+ retry_max_backoff = config.retry_max_backoff
308
+ load_balancer = config.load_balancer
309
+ health_check_interval = config.health_check_interval
310
+
311
+ if endpoints is None:
312
+ raise ValueError("Endpoints must be provided")
313
+
314
+ if not endpoints:
315
+ raise ValueError("Endpoints list cannot be empty")
316
+
317
+ self.metadata = None
318
+ self.failover = failover
319
+ self.max_retries = max_retries
320
+ self.retry_backoff = retry_backoff
321
+ self.retry_max_backoff = retry_max_backoff
322
+ self.load_balancer = load_balancer or utils.LoadBalancerStrategy.PRIORITY
323
+ self.health_check_interval = health_check_interval
324
+ self._rr_index = 0 # Round-robin index
325
+
326
+ # Endpoint lock for thread-safe endpoint management
327
+ self._endpoint_lock = threading.Lock()
328
+
329
+ # Step 1: setup endpoints
330
+ self.endpoints = {ep.netloc: ep for ep in endpoints}
331
+ self._current_ep_label = random.choice(list(self.endpoints.keys()))
332
+
333
+ # Step 2: if auth is enabled, call the auth endpoint
334
+ self.timeout = timeout
335
+ self.call_credentials = None
336
+ cred_params = [c is not None for c in (user, password)]
337
+
338
+ if all(cred_params):
339
+ auth_request = etcdrpc.AuthenticateRequest(name=user, password=password)
340
+
341
+ # Create auth stub directly (no caching needed)
342
+ auth_stub = etcdrpc.AuthStub(self.channel)
343
+ resp = auth_stub.Authenticate(auth_request, self.timeout)
344
+ self.metadata = (("token", resp.token),)
345
+ self.call_credentials = grpc.metadata_call_credentials(
346
+ EtcdTokenCallCredentials(resp.token)
347
+ )
348
+
349
+ elif any(cred_params):
350
+ raise Exception(
351
+ "if using authentication credentials both user and password "
352
+ "must be specified."
353
+ )
354
+
355
+ self.transactions = Transactions()
356
+
357
+ # Initialize metrics
358
+ self.metrics = get_metrics_interface()
359
+
360
+ @property
361
+ def _current_endpoint_label(self):
362
+ return self._current_ep_label
363
+
364
+ @_current_endpoint_label.setter
365
+ def _current_endpoint_label(self, value):
366
+ with self._endpoint_lock:
367
+ self._current_ep_label = value
368
+
369
+ @property
370
+ def endpoint_in_use(self):
371
+ """Get the current endpoint in use."""
372
+ if self._current_endpoint_label is None:
373
+ return None
374
+ return self.endpoints[self._current_endpoint_label]
375
+
376
+ @property
377
+ def channel(self):
378
+ """
379
+ Get an available channel on the first node that's not failed.
380
+
381
+ Raises an exception if no node is available
382
+ """
383
+ with self._endpoint_lock:
384
+ try:
385
+ channel = self.endpoint_in_use.use()
386
+ # Record endpoint usage
387
+ self.metrics.record_endpoint_usage(str(self.endpoint_in_use))
388
+ return channel
389
+ except ValueError:
390
+ if not self.failover:
391
+ raise
392
+ # We're failing over. We get the first non-failed channel
393
+ # we encounter, and use it by calling this function again,
394
+ # recursively
395
+ for label, endpoint in self.endpoints.items():
396
+ if endpoint.is_failed():
397
+ continue
398
+ self._current_endpoint_label = label
399
+ channel = self.endpoint_in_use.use()
400
+ # Record endpoint usage for the new endpoint
401
+ self.metrics.record_endpoint_usage(str(self.endpoint_in_use))
402
+ return channel
403
+ raise exceptions.NoServerAvailableError(
404
+ "No endpoint available and not failed"
405
+ )
406
+
407
+ def _get_next_endpoint(self):
408
+ """Get the next endpoint based on load balancing strategy.
409
+
410
+ :returns: A tuple of (endpoint_label, endpoint) or (None, None) if no healthy endpoint
411
+ """
412
+ healthy_endpoints = [
413
+ (label, ep) for label, ep in self.endpoints.items() if not ep.is_failed()
414
+ ]
415
+
416
+ if not healthy_endpoints:
417
+ return None, None
418
+
419
+ if self.load_balancer == utils.LoadBalancerStrategy.ROUND_ROBIN:
420
+ # Round-robin: select next endpoint in order
421
+ self._rr_index = (self._rr_index + 1) % len(healthy_endpoints)
422
+ return healthy_endpoints[self._rr_index - 1]
423
+
424
+ elif self.load_balancer == utils.LoadBalancerStrategy.RANDOM:
425
+ # Random: select random endpoint
426
+ return random.choice(healthy_endpoints)
427
+
428
+ elif self.load_balancer == utils.LoadBalancerStrategy.STICKY:
429
+ # Sticky: keep current endpoint if healthy
430
+ current = self.endpoint_in_use
431
+ if current is not None and not current.is_failed():
432
+ return self._current_ep_label, current
433
+ # Fallback to first healthy endpoint
434
+ return healthy_endpoints[0]
435
+
436
+ else:
437
+ # PRIORITY (default): keep current endpoint if healthy
438
+ current = self.endpoint_in_use
439
+ if current is not None and not current.is_failed():
440
+ return self._current_ep_label, current
441
+ # Fallback to first healthy endpoint
442
+ return healthy_endpoints[0]
443
+
444
+ def _try_rebuild_channel(self, endpoint):
445
+ """Try to rebuild the channel, return True on success, False on failure."""
446
+ try:
447
+ endpoint.rebuild_channel()
448
+ return True
449
+ except Exception:
450
+ return False
451
+
452
+ def _get_channel(self):
453
+ """Get an available channel with automatic reconnection support.
454
+
455
+ Uses a two-phase locking strategy:
456
+ 1. Quick status check (locked)
457
+ 2. I/O operations outside the lock
458
+ 3. Status update (locked again)
459
+
460
+ :returns: gRPC channel
461
+ :rtype: grpc.Channel
462
+ """
463
+ # Phase 1: Quick status check (locked)
464
+ with self._endpoint_lock:
465
+ endpoint = self.endpoint_in_use
466
+ if endpoint is None:
467
+ raise ValueError("No current endpoint")
468
+
469
+ # Check if endpoint has recovered health
470
+ if endpoint.is_failed() and endpoint.is_healthy():
471
+ # Recovered health, reset failure mark
472
+ endpoint.last_failed = 0
473
+ endpoint.in_use = True
474
+ self.metrics.record_endpoint_recovery(str(endpoint))
475
+ channel = endpoint.channel
476
+ self.metrics.record_endpoint_usage(str(endpoint))
477
+ return channel
478
+
479
+ needs_rebuild = endpoint.is_failed()
480
+
481
+ # Phase 2: I/O operations outside the lock (critical optimization)
482
+ rebuild_success = False
483
+ if needs_rebuild:
484
+ rebuild_success = self._try_rebuild_channel(endpoint)
485
+ if not rebuild_success:
486
+ raise exceptions.NoServerAvailableError("Failed to rebuild connection")
487
+
488
+ # Phase 3: Update status (acquire lock again)
489
+ with self._endpoint_lock:
490
+ # Double-check: confirm status wasn't modified by other threads during I/O
491
+ if endpoint.is_failed() and endpoint.is_healthy():
492
+ # Status has recovered
493
+ endpoint.last_failed = 0
494
+ endpoint.in_use = True
495
+ self.metrics.record_endpoint_recovery(str(endpoint))
496
+ elif needs_rebuild and not endpoint.is_failed():
497
+ # Status after successful rebuild
498
+ endpoint.in_use = True
499
+ self.metrics.record_endpoint_recovery(str(endpoint))
500
+
501
+ channel = endpoint.channel
502
+ self.metrics.record_endpoint_usage(str(endpoint))
503
+ return channel
504
+
505
+ def close(self):
506
+ """Call the GRPC channel close semantics."""
507
+ for endpoint in self.endpoints.values():
508
+ endpoint.close()
509
+
510
+ def __enter__(self):
511
+ return self
512
+
513
+ def __exit__(self, *args):
514
+ self.close()
515
+
516
+ def _manage_grpc_errors(self, exc):
517
+ """Handle gRPC errors and convert to library exceptions."""
518
+ code = exc.code()
519
+ if code in _FAILED_EP_CODES:
520
+ # This sets the current node to failed.
521
+ # If others are available, they will be used on
522
+ # subsequent requests.
523
+ with self._endpoint_lock:
524
+ self.endpoint_in_use.fail()
525
+ # Record endpoint failure for monitoring
526
+ if hasattr(self, "monitor") and self.endpoint_in_use:
527
+ self.metrics.record_endpoint_failure(str(self.endpoint_in_use))
528
+ exception = _EXCEPTIONS_BY_CODE.get(code)
529
+ if exception is None:
530
+ raise exc # Re-raise the original exception
531
+ raise exception()
532
+
533
+ def _should_retry(self, exc):
534
+ """Check if the error is retryable."""
535
+ code = getattr(exc, "code", lambda: None)()
536
+ if code in [
537
+ grpc.StatusCode.UNAVAILABLE,
538
+ grpc.StatusCode.DEADLINE_EXCEEDED,
539
+ grpc.StatusCode.INTERNAL,
540
+ grpc.StatusCode.RESOURCE_EXHAUSTED,
541
+ ]:
542
+ return True
543
+ return False
544
+
545
+ def _retry_with_backoff(self, func, *args, **kwargs):
546
+ """Retry function with exponential backoff."""
547
+ retries = 0
548
+ backoff = self.retry_backoff
549
+
550
+ while retries <= self.max_retries:
551
+ try:
552
+ return func(*args, **kwargs)
553
+ except Exception as exc:
554
+ if not self._should_retry(exc) or retries >= self.max_retries:
555
+ raise
556
+
557
+ retries += 1
558
+ if retries > self.max_retries:
559
+ raise
560
+
561
+ # Record retry
562
+ self.metrics.record_retry(func.__name__, "unknown")
563
+
564
+ # Exponential backoff with jitter
565
+ backoff_time = min(
566
+ backoff * (2 ** (retries - 1)), self.retry_max_backoff
567
+ )
568
+ jitter = random.uniform(0, backoff_time * 0.1)
569
+ time.sleep(backoff_time + jitter)
570
+
571
+ def _build_get_range_request(
572
+ self,
573
+ key,
574
+ range_end=None,
575
+ limit=None,
576
+ revision=None,
577
+ sort_order=None,
578
+ sort_target="key",
579
+ serializable=False,
580
+ keys_only=False,
581
+ count_only=False,
582
+ min_mod_revision=None,
583
+ max_mod_revision=None,
584
+ min_create_revision=None,
585
+ max_create_revision=None,
586
+ ):
587
+ return RequestBuilder.build_get_range_request(
588
+ key=key,
589
+ range_end=range_end,
590
+ limit=limit,
591
+ revision=revision,
592
+ sort_order=sort_order,
593
+ sort_target=sort_target,
594
+ serializable=serializable,
595
+ keys_only=keys_only,
596
+ count_only=count_only,
597
+ min_mod_revision=min_mod_revision,
598
+ max_mod_revision=max_mod_revision,
599
+ min_create_revision=min_create_revision,
600
+ max_create_revision=max_create_revision,
601
+ )
602
+
603
+ @_sync_handle_errors
604
+ @monitor_request("kv.get_response")
605
+ @log_operation("kv.get")
606
+ def get_response(self, key, **kwargs):
607
+ """Get the value of a key from etcd."""
608
+ range_request = self._build_get_range_request(key, **kwargs)
609
+
610
+ # Create KV stub directly
611
+ kv_stub = etcdrpc.KVStub(self.channel)
612
+ return kv_stub.Range(
613
+ range_request,
614
+ self.timeout,
615
+ credentials=self.call_credentials,
616
+ metadata=self.metadata,
617
+ )
618
+
619
+ def get(self, key, **kwargs):
620
+ """
621
+ Get the value of a key from etcd.
622
+
623
+ example usage:
624
+
625
+ .. code-block:: python
626
+
627
+ >>> import etcd3
628
+ >>> etcd = etcd3.client()
629
+ >>> etcd.get('/thing/key')
630
+ 'hello world'
631
+
632
+ :param key: key in etcd to get
633
+ :returns: value of key and metadata
634
+ :rtype: bytes, ``KVMetadata``
635
+ """
636
+ range_response = self.get_response(key, **kwargs)
637
+ if range_response.count < 1:
638
+ return None, None
639
+ else:
640
+ kv = range_response.kvs.pop()
641
+ return kv.value, KVMetadata(kv, range_response.header)
642
+
643
+ @_sync_handle_errors
644
+ def get_prefix_response(self, key_prefix, **kwargs):
645
+ """Get a range of keys with a prefix."""
646
+ if any(kwarg in kwargs for kwarg in ("key", "range_end")):
647
+ raise TypeError("Don't use key or range_end with prefix")
648
+
649
+ range_request = self._build_get_range_request(
650
+ key=key_prefix,
651
+ range_end=utils.prefix_range_end(utils.to_bytes(key_prefix)),
652
+ **kwargs,
653
+ )
654
+
655
+ # Create KV stub directly
656
+ kv_stub = etcdrpc.KVStub(self.channel)
657
+ return kv_stub.Range(
658
+ range_request,
659
+ self.timeout,
660
+ credentials=self.call_credentials,
661
+ metadata=self.metadata,
662
+ )
663
+
664
+ def get_prefix(self, key_prefix, **kwargs):
665
+ """
666
+ Get a range of keys with a prefix.
667
+
668
+ :param key_prefix: first key in range
669
+ :param keys_only: if True, retrieve only the keys, not the values
670
+
671
+ :returns: sequence of (value, metadata) tuples
672
+ """
673
+ range_response = self.get_prefix_response(key_prefix, **kwargs)
674
+ return (
675
+ (kv.value, KVMetadata(kv, range_response.header))
676
+ for kv in range_response.kvs
677
+ )
678
+
679
+ @_sync_handle_errors
680
+ def get_range_response(
681
+ self, range_start, range_end, sort_order=None, sort_target="key", **kwargs
682
+ ):
683
+ """Get a range of keys."""
684
+ range_request = self._build_get_range_request(
685
+ key=range_start,
686
+ range_end=range_end,
687
+ sort_order=sort_order,
688
+ sort_target=sort_target,
689
+ **kwargs,
690
+ )
691
+
692
+ # Create KV stub directly
693
+ kv_stub = etcdrpc.KVStub(self.channel)
694
+ return kv_stub.Range(
695
+ range_request,
696
+ self.timeout,
697
+ credentials=self.call_credentials,
698
+ metadata=self.metadata,
699
+ )
700
+
701
+ def get_range(self, range_start, range_end, **kwargs):
702
+ """
703
+ Get a range of keys.
704
+
705
+ :param range_start: first key in range
706
+ :param range_end: last key in range
707
+ :returns: sequence of (value, metadata) tuples
708
+ """
709
+ range_response = self.get_range_response(range_start, range_end, **kwargs)
710
+ for kv in range_response.kvs:
711
+ yield (kv.value, KVMetadata(kv, range_response.header))
712
+
713
+ @_sync_handle_errors
714
+ def get_all_response(self, sort_order=None, sort_target="key", keys_only=False):
715
+ """Get all keys currently stored in etcd."""
716
+ # Use b'\x00' as the minimum key and b'\xff' * 16 as range_end to get all keys
717
+ # This works because etcd stores keys in sorted order by key bytes
718
+ # b'\x00' is the smallest non-empty key, and b'\xff' * 16 is a key that is
719
+ # larger than any valid key (max key length in etcd is 16 bytes)
720
+ range_request = self._build_get_range_request(
721
+ key=b"\x00",
722
+ range_end=b"\xff" * 16,
723
+ sort_order=sort_order,
724
+ sort_target=sort_target,
725
+ keys_only=keys_only,
726
+ )
727
+
728
+ # Create KV stub directly
729
+ kv_stub = etcdrpc.KVStub(self.channel)
730
+ return kv_stub.Range(
731
+ range_request,
732
+ self.timeout,
733
+ credentials=self.call_credentials,
734
+ metadata=self.metadata,
735
+ )
736
+
737
+ def get_all(self, **kwargs):
738
+ """
739
+ Get all keys currently stored in etcd.
740
+
741
+ :param keys_only: if True, retrieve only the keys, not the values
742
+ :returns: sequence of (value, metadata) tuples
743
+ """
744
+ range_response = self.get_all_response(**kwargs)
745
+ for kv in range_response.kvs:
746
+ yield (kv.value, KVMetadata(kv, range_response.header))
747
+
748
+ def _build_put_request(
749
+ self,
750
+ key,
751
+ value=None,
752
+ lease=None,
753
+ prev_kv=False,
754
+ ignore_value=False,
755
+ ignore_lease=False,
756
+ ):
757
+ return RequestBuilder.build_put_request(
758
+ key=key,
759
+ value=value,
760
+ lease=lease,
761
+ prev_kv=prev_kv,
762
+ ignore_value=ignore_value,
763
+ ignore_lease=ignore_lease,
764
+ )
765
+
766
+ @_sync_handle_errors
767
+ @monitor_request("kv.put")
768
+ @log_operation("kv.put")
769
+ def put(
770
+ self,
771
+ key,
772
+ value=None,
773
+ lease=None,
774
+ prev_kv=False,
775
+ ignore_value=False,
776
+ ignore_lease=False,
777
+ ):
778
+ """
779
+ Save a value to etcd.
780
+
781
+ Example usage:
782
+
783
+ .. code-block:: python
784
+
785
+ >>> import etcd3
786
+ >>> etcd = etcd3.client()
787
+ >>> etcd.put('/thing/key', 'hello world')
788
+
789
+ :param key: key in etcd to set
790
+ :param value: value to set key to
791
+ :type value: bytes
792
+ :param lease: Lease to associate with this key.
793
+ :type lease: either :class:`.Lease`, or int (ID of lease)
794
+ :param prev_kv: return the previous key-value pair
795
+ :type prev_kv: bool
796
+ :param ignore_value: if True, update the key using the current value
797
+ :type ignore_value: bool
798
+ :param ignore_lease: if True, update the key using the current lease
799
+ :type ignore_lease: bool
800
+ :returns: a response containing a header and the prev_kv
801
+ :rtype: :class:`.rpc_pb2.PutResponse`
802
+ """
803
+ put_request = self._build_put_request(
804
+ key,
805
+ value=value,
806
+ lease=lease,
807
+ prev_kv=prev_kv,
808
+ ignore_value=ignore_value,
809
+ ignore_lease=ignore_lease,
810
+ )
811
+ # Create KV stub directly
812
+ kv_stub = etcdrpc.KVStub(self.channel)
813
+ return kv_stub.Put(
814
+ put_request,
815
+ self.timeout,
816
+ credentials=self.call_credentials,
817
+ metadata=self.metadata,
818
+ )
819
+
820
+ @_sync_handle_errors
821
+ def put_if_not_exists(self, key, value, lease=None):
822
+ """
823
+ Atomically puts a value only if the key previously had no value.
824
+
825
+ This is the etcdv3 equivalent to setting a key with the etcdv2
826
+ parameter prevExist=false.
827
+
828
+ :param key: key in etcd to put
829
+ :param value: value to be written to key
830
+ :type value: bytes
831
+ :param lease: Lease to associate with this key.
832
+ :type lease: either :class:`.Lease`, or int (ID of lease)
833
+ :returns: state of transaction, ``True`` if the put was successful,
834
+ ``False`` otherwise
835
+ :rtype: bool
836
+ """
837
+ status, _ = self.transaction(
838
+ compare=[self.transactions.create(key) == "0"],
839
+ success=[self.transactions.put(key, value, lease=lease)],
840
+ failure=[],
841
+ )
842
+
843
+ return status
844
+
845
+ @_sync_handle_errors
846
+ def replace(self, key, initial_value, new_value):
847
+ """
848
+ Atomically replace the value of a key with a new value.
849
+
850
+ This compares the current value of a key, then replaces it with a new
851
+ value if it is equal to a specified value. This operation takes place
852
+ in a transaction.
853
+
854
+ :param key: key in etcd to replace
855
+ :param initial_value: old value to replace
856
+ :type initial_value: bytes
857
+ :param new_value: new value of the key
858
+ :type new_value: bytes
859
+ :returns: status of transaction, ``True`` if the replace was
860
+ successful, ``False`` otherwise
861
+ :rtype: bool
862
+ """
863
+ status, _ = self.transaction(
864
+ compare=[self.transactions.value(key) == initial_value],
865
+ success=[self.transactions.put(key, new_value)],
866
+ failure=[],
867
+ )
868
+
869
+ return status
870
+
871
+ def _build_delete_request(self, key, range_end=None, prev_kv=False):
872
+ return RequestBuilder.build_delete_request(
873
+ key=key,
874
+ range_end=range_end,
875
+ prev_kv=prev_kv,
876
+ )
877
+
878
+ @_sync_handle_errors
879
+ @monitor_request("kv.delete")
880
+ @log_operation("kv.delete")
881
+ def delete(self, key, prev_kv=False, return_response=False, range_end=None):
882
+ """
883
+ Delete a key or range of keys in etcd.
884
+
885
+ :param key: key in etcd to delete
886
+ :type key: str or bytes
887
+ :param prev_kv: return the deleted key-value pair
888
+ :type prev_kv: bool
889
+ :param return_response: return the full response
890
+ :type return_response: bool
891
+ :param range_end: End of the key range (for range deletes)
892
+ :type range_end: str or bytes, optional
893
+ :returns: True if the key has been deleted when
894
+ ``return_response`` is False and a response containing
895
+ a header, the number of deleted keys and prev_kvs when
896
+ ``return_response`` is True
897
+ """
898
+ delete_request = self._build_delete_request(
899
+ key, range_end=range_end, prev_kv=prev_kv
900
+ )
901
+ # Create KV stub directly
902
+ kv_stub = etcdrpc.KVStub(self.channel)
903
+ delete_response = kv_stub.DeleteRange(
904
+ delete_request,
905
+ self.timeout,
906
+ credentials=self.call_credentials,
907
+ metadata=self.metadata,
908
+ )
909
+ if return_response:
910
+ return delete_response
911
+ return delete_response.deleted >= 1
912
+
913
+ @_sync_handle_errors
914
+ @monitor_request("kv.delete_prefix")
915
+ @log_operation("kv.delete_prefix")
916
+ def delete_prefix(self, prefix):
917
+ """Delete a range of keys with a prefix in etcd."""
918
+ delete_request = self._build_delete_request(
919
+ prefix, range_end=utils.prefix_range_end(utils.to_bytes(prefix))
920
+ )
921
+ # Create KV stub directly
922
+ kv_stub = etcdrpc.KVStub(self.channel)
923
+ return kv_stub.DeleteRange(
924
+ delete_request,
925
+ self.timeout,
926
+ credentials=self.call_credentials,
927
+ metadata=self.metadata,
928
+ )
929
+
930
+ @_sync_handle_errors
931
+ @monitor_request("maintenance.status")
932
+ @log_operation("maintenance.status")
933
+ def status(self):
934
+ """Get the status of the responding member."""
935
+ status_request = etcdrpc.StatusRequest()
936
+ # Create maintenance stub directly
937
+ maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
938
+ status_response = maintenance_stub.Status(
939
+ status_request,
940
+ self.timeout,
941
+ credentials=self.call_credentials,
942
+ metadata=self.metadata,
943
+ )
944
+
945
+ for m in self.members:
946
+ if m.id == status_response.leader:
947
+ leader = m
948
+ break
949
+ else:
950
+ # raise exception?
951
+ leader = None
952
+
953
+ return Status(
954
+ status_response.version,
955
+ status_response.dbSize,
956
+ leader,
957
+ status_response.raftIndex,
958
+ status_response.raftTerm,
959
+ )
960
+
961
+ def _get_watcher(self):
962
+ """Create a new watcher instance."""
963
+ watchstub = etcdrpc.WatchStub(self.channel)
964
+ return watch.Watcher(
965
+ watchstub,
966
+ timeout=self.timeout,
967
+ call_credentials=self.call_credentials,
968
+ metadata=self.metadata,
969
+ )
970
+
971
+ @_sync_handle_errors
972
+ @monitor_request("watch.add_callback")
973
+ @log_operation("watch.add_callback")
974
+ def add_watch_callback(self, *args, **kwargs):
975
+ """
976
+ Watch a key or range of keys and call a callback on every response.
977
+
978
+ If timeout was declared during the client initialization and
979
+ the watch cannot be created during that time the method raises
980
+ a ``WatchTimedOut`` exception.
981
+
982
+ :param key: key to watch
983
+ :param callback: callback function
984
+
985
+ :returns: watch_id. Later it could be used for cancelling watch.
986
+ """
987
+ watcher = self._get_watcher()
988
+ try:
989
+ return watcher.add_callback(*args, **kwargs)
990
+ except queue.Empty:
991
+ raise exceptions.WatchTimedOut()
992
+
993
+ @_sync_handle_errors
994
+ def add_watch_prefix_callback(self, key_prefix, callback, **kwargs):
995
+ """
996
+ Watch a prefix and call a callback on every response.
997
+
998
+ If timeout was declared during the client initialization and
999
+ the watch cannot be created during that time the method raises
1000
+ a ``WatchTimedOut`` exception.
1001
+
1002
+ :param key_prefix: prefix to watch
1003
+ :param callback: callback function
1004
+
1005
+ :returns: watch_id. Later it could be used for cancelling watch.
1006
+ """
1007
+ kwargs["range_end"] = utils.prefix_range_end(utils.to_bytes(key_prefix))
1008
+
1009
+ return self.add_watch_callback(key_prefix, callback, **kwargs)
1010
+
1011
+ @_sync_handle_errors
1012
+ def watch_response(self, key, timeout=None, **kwargs):
1013
+ """
1014
+ Watch a key.
1015
+
1016
+ Example usage:
1017
+
1018
+ .. code-block:: python
1019
+
1020
+ responses_iterator, cancel = etcd.watch_response('/doot/key')
1021
+ for response in responses_iterator:
1022
+ print(response)
1023
+
1024
+ :param key: key to watch
1025
+ :param timeout: (optional) timeout in seconds for each response.
1026
+ If None, blocks indefinitely.
1027
+
1028
+ :returns: tuple of ``responses_iterator`` and ``cancel``.
1029
+ Use ``responses_iterator`` to get the watch responses,
1030
+ each of which contains a header and a list of events.
1031
+ Use ``cancel`` to cancel the watch request.
1032
+ """
1033
+ response_queue = queue.Queue()
1034
+
1035
+ def callback(response):
1036
+ response_queue.put(response)
1037
+
1038
+ watch_id = self.add_watch_callback(key, callback, **kwargs)
1039
+ canceled = threading.Event()
1040
+
1041
+ def cancel():
1042
+ canceled.set()
1043
+ response_queue.put(None)
1044
+ self.cancel_watch(watch_id)
1045
+
1046
+ def iterator():
1047
+ try:
1048
+ while not canceled.is_set():
1049
+ try:
1050
+ response = response_queue.get(timeout=timeout)
1051
+ except queue.Empty:
1052
+ # Timeout reached, exit iterator
1053
+ canceled.set()
1054
+ return
1055
+ if response is None:
1056
+ canceled.set()
1057
+ if isinstance(response, Exception):
1058
+ canceled.set()
1059
+ raise response
1060
+ if not canceled.is_set():
1061
+ yield response
1062
+ except grpc.RpcError as exc:
1063
+ self._manage_grpc_errors(exc)
1064
+
1065
+ return iterator(), cancel
1066
+
1067
+ def watch(self, key, timeout=None, **kwargs):
1068
+ """
1069
+ Watch a key.
1070
+
1071
+ Example usage:
1072
+
1073
+ .. code-block:: python
1074
+
1075
+ events_iterator, cancel = etcd.watch('/doot/key')
1076
+ for event in events_iterator:
1077
+ print(event)
1078
+
1079
+ :param key: key to watch
1080
+ :param timeout: (optional) timeout in seconds for each response.
1081
+ If None, blocks indefinitely.
1082
+
1083
+ :returns: tuple of ``events_iterator`` and ``cancel``.
1084
+ Use ``events_iterator`` to get the events of key changes
1085
+ and ``cancel`` to cancel the watch request.
1086
+ """
1087
+ response_iterator, cancel = self.watch_response(key, timeout=timeout, **kwargs)
1088
+ return utils.response_to_event_iterator(response_iterator), cancel
1089
+
1090
+ def watch_prefix_response(self, key_prefix, timeout=None, **kwargs):
1091
+ """
1092
+ Watch a range of keys with a prefix.
1093
+
1094
+ :param key_prefix: prefix to watch
1095
+ :param timeout: (optional) timeout in seconds for each response.
1096
+ If None, blocks indefinitely.
1097
+
1098
+ :returns: tuple of ``responses_iterator`` and ``cancel``.
1099
+ """
1100
+ kwargs["range_end"] = utils.prefix_range_end(utils.to_bytes(key_prefix))
1101
+ return self.watch_response(key_prefix, timeout=timeout, **kwargs)
1102
+
1103
+ def watch_prefix(self, key_prefix, timeout=None, **kwargs):
1104
+ """
1105
+ Watch a range of keys with a prefix.
1106
+
1107
+ :param key_prefix: prefix to watch
1108
+ :param timeout: (optional) timeout in seconds for each response.
1109
+ If None, blocks indefinitely.
1110
+
1111
+ :returns: tuple of ``events_iterator`` and ``cancel``.
1112
+ """
1113
+ kwargs["range_end"] = utils.prefix_range_end(utils.to_bytes(key_prefix))
1114
+ return self.watch(key_prefix, timeout=timeout, **kwargs)
1115
+
1116
+ @_sync_handle_errors
1117
+ def watch_once_response(self, key, timeout=None, **kwargs):
1118
+ """
1119
+ Watch a key and stop after the first response.
1120
+
1121
+ If the timeout was specified and response didn't arrive method
1122
+ will raise ``WatchTimedOut`` exception.
1123
+
1124
+ :param key: key to watch
1125
+ :param timeout: (optional) timeout in seconds.
1126
+
1127
+ :returns: ``WatchResponse``
1128
+ """
1129
+ response_queue = queue.Queue()
1130
+
1131
+ def callback(response):
1132
+ response_queue.put(response)
1133
+
1134
+ watch_id = self.add_watch_callback(key, callback, **kwargs)
1135
+
1136
+ try:
1137
+ return response_queue.get(timeout=timeout)
1138
+ except queue.Empty:
1139
+ raise exceptions.WatchTimedOut()
1140
+ finally:
1141
+ self.cancel_watch(watch_id)
1142
+
1143
+ def watch_once(self, key, timeout=None, **kwargs):
1144
+ """
1145
+ Watch a key and stop after the first event.
1146
+
1147
+ If the timeout was specified and event didn't arrive method
1148
+ will raise ``WatchTimedOut`` exception.
1149
+
1150
+ :param key: key to watch
1151
+ :param timeout: (optional) timeout in seconds.
1152
+
1153
+ :returns: ``Event``
1154
+ """
1155
+ response = self.watch_once_response(key, timeout=timeout, **kwargs)
1156
+ if not response.events:
1157
+ raise IndexError("No events in watch response")
1158
+ return response.events[0]
1159
+
1160
+ def watch_prefix_once_response(self, key_prefix, timeout=None, **kwargs):
1161
+ """
1162
+ Watch a range of keys with a prefix and stop after the first response.
1163
+
1164
+ If the timeout was specified and response didn't arrive method
1165
+ will raise ``WatchTimedOut`` exception.
1166
+ """
1167
+ kwargs["range_end"] = utils.prefix_range_end(utils.to_bytes(key_prefix))
1168
+ return self.watch_once_response(key_prefix, timeout=timeout, **kwargs)
1169
+
1170
+ def watch_prefix_once(self, key_prefix, timeout=None, **kwargs):
1171
+ """
1172
+ Watch a range of keys with a prefix and stop after the first event.
1173
+
1174
+ If the timeout was specified and event didn't arrive method
1175
+ will raise ``WatchTimedOut`` exception.
1176
+ """
1177
+ kwargs["range_end"] = utils.prefix_range_end(utils.to_bytes(key_prefix))
1178
+ return self.watch_once(key_prefix, timeout=timeout, **kwargs)
1179
+
1180
+ @_sync_handle_errors
1181
+ @monitor_request("watch.cancel")
1182
+ @log_operation("watch.cancel")
1183
+ def cancel_watch(self, watch_id):
1184
+ """
1185
+ Stop watching a key or range of keys.
1186
+
1187
+ :param watch_id: watch_id returned by ``add_watch_callback`` method
1188
+ """
1189
+ watcher = self._get_watcher()
1190
+ watcher.cancel(watch_id)
1191
+
1192
+ def _ops_to_requests(self, ops):
1193
+ """
1194
+ Return a list of grpc requests.
1195
+
1196
+ Returns list from an input list of etcd3.transactions.{Put, Get,
1197
+ Delete, Txn} objects.
1198
+ """
1199
+ request_ops = []
1200
+ for op in ops:
1201
+ if isinstance(op, transactions.Put):
1202
+ request = self._build_put_request(
1203
+ op.key,
1204
+ op.value,
1205
+ op.lease,
1206
+ op.prev_kv,
1207
+ op.ignore_value,
1208
+ op.ignore_lease,
1209
+ )
1210
+ request_op = etcdrpc.RequestOp(request_put=request)
1211
+ request_ops.append(request_op)
1212
+
1213
+ elif isinstance(op, transactions.Get):
1214
+ request = self._build_get_range_request(op.key, op.range_end)
1215
+ request_op = etcdrpc.RequestOp(request_range=request)
1216
+ request_ops.append(request_op)
1217
+
1218
+ elif isinstance(op, transactions.Delete):
1219
+ request = self._build_delete_request(op.key, op.range_end, op.prev_kv)
1220
+ request_op = etcdrpc.RequestOp(request_delete_range=request)
1221
+ request_ops.append(request_op)
1222
+
1223
+ elif isinstance(op, transactions.Txn):
1224
+ compare = [c.build_message() for c in op.compare]
1225
+ success_ops = self._ops_to_requests(op.success)
1226
+ failure_ops = self._ops_to_requests(op.failure)
1227
+ request = etcdrpc.TxnRequest(
1228
+ compare=compare, success=success_ops, failure=failure_ops
1229
+ )
1230
+ request_op = etcdrpc.RequestOp(request_txn=request)
1231
+ request_ops.append(request_op)
1232
+
1233
+ else:
1234
+ raise Exception("Unknown request class {}".format(op.__class__))
1235
+ return request_ops
1236
+
1237
+ @_sync_handle_errors
1238
+ @monitor_request("kv.transaction")
1239
+ @log_operation("kv.transaction")
1240
+ def transaction(self, compare, success=None, failure=None):
1241
+ """
1242
+ Perform a transaction.
1243
+
1244
+ Example usage:
1245
+
1246
+ .. code-block:: python
1247
+
1248
+ etcd.transaction(
1249
+ compare=[
1250
+ etcd.transactions.value('/doot/testing') == 'doot',
1251
+ etcd.transactions.version('/doot/testing') > 0,
1252
+ ],
1253
+ success=[
1254
+ etcd.transactions.put('/doot/testing', 'success'),
1255
+ ],
1256
+ failure=[
1257
+ etcd.transactions.put('/doot/testing', 'failure')
1258
+ ]
1259
+ )
1260
+
1261
+ :param compare: A list of comparisons to make
1262
+ :param success: A list of operations to perform if all the comparisons
1263
+ are true
1264
+ :param failure: A list of operations to perform if any of the
1265
+ comparisons are false
1266
+ :return: A tuple of (operation status, responses)
1267
+ """
1268
+ compare = [c.build_message() for c in compare]
1269
+
1270
+ success_ops = self._ops_to_requests(success)
1271
+ failure_ops = self._ops_to_requests(failure)
1272
+
1273
+ transaction_request = etcdrpc.TxnRequest(
1274
+ compare=compare, success=success_ops, failure=failure_ops
1275
+ )
1276
+
1277
+ # Create KV stub directly
1278
+ kv_stub = etcdrpc.KVStub(self.channel)
1279
+ txn_response = kv_stub.Txn(
1280
+ transaction_request,
1281
+ self.timeout,
1282
+ credentials=self.call_credentials,
1283
+ metadata=self.metadata,
1284
+ )
1285
+
1286
+ responses = []
1287
+ for response in txn_response.responses:
1288
+ response_type = response.WhichOneof("response")
1289
+ if response_type == "response_range":
1290
+ responses.append(
1291
+ [
1292
+ (kv.value, KVMetadata(kv, txn_response.header))
1293
+ for kv in response.response_range.kvs
1294
+ ]
1295
+ )
1296
+ elif response_type == "response_put":
1297
+ responses.append(response.response_put)
1298
+ elif response_type == "response_delete_range":
1299
+ responses.append(response.response_delete_range)
1300
+ elif response_type == "response_txn":
1301
+ responses.append(response.response_txn)
1302
+ else:
1303
+ # Unknown response type - raise error for visibility
1304
+ raise exceptions.InternalServerError(
1305
+ f"Unknown transaction response type: {response_type}"
1306
+ )
1307
+
1308
+ return txn_response.succeeded, responses
1309
+
1310
+ @_sync_handle_errors
1311
+ @monitor_request("lease.create")
1312
+ @log_operation("lease.create")
1313
+ def lease(self, ttl, lease_id=None):
1314
+ """
1315
+ Create a new lease.
1316
+
1317
+ All keys attached to this lease will be expired and deleted if the
1318
+ lease expires. A lease can be sent keep alive messages to refresh the
1319
+ ttl.
1320
+
1321
+ :param ttl: Requested time to live
1322
+ :param lease_id: Requested ID for the lease
1323
+
1324
+ :returns: new lease
1325
+ :rtype: :class:`.Lease`
1326
+ """
1327
+ lease_grant_request = etcdrpc.LeaseGrantRequest(TTL=ttl, ID=lease_id)
1328
+ # Create lease stub directly
1329
+ lease_stub = etcdrpc.LeaseStub(self.channel)
1330
+ lease_grant_response = lease_stub.LeaseGrant(
1331
+ lease_grant_request,
1332
+ self.timeout,
1333
+ credentials=self.call_credentials,
1334
+ metadata=self.metadata,
1335
+ )
1336
+ return leases.Lease(
1337
+ lease_id=lease_grant_response.ID,
1338
+ ttl=lease_grant_response.TTL,
1339
+ etcd_client=self,
1340
+ )
1341
+
1342
+ @_sync_handle_errors
1343
+ @monitor_request("lease.revoke")
1344
+ @log_operation("lease.revoke")
1345
+ def revoke_lease(self, lease_id):
1346
+ """
1347
+ Revoke a lease.
1348
+
1349
+ :param lease_id: ID of the lease to revoke.
1350
+ """
1351
+ lease_revoke_request = etcdrpc.LeaseRevokeRequest(ID=lease_id)
1352
+ # Create lease stub directly
1353
+ lease_stub = etcdrpc.LeaseStub(self.channel)
1354
+ lease_stub.LeaseRevoke(
1355
+ lease_revoke_request,
1356
+ self.timeout,
1357
+ credentials=self.call_credentials,
1358
+ metadata=self.metadata,
1359
+ )
1360
+
1361
+ @_sync_handle_keepalive_errors
1362
+ def refresh_lease(
1363
+ self,
1364
+ lease_id,
1365
+ max_consecutive_errors=10,
1366
+ base_retry_delay=1.0,
1367
+ max_retry_delay=30.0,
1368
+ ):
1369
+ """Refresh a lease continuously with automatic reconnection.
1370
+
1371
+ This method creates a long-lived gRPC stream that sends keep-alive requests
1372
+ periodically to maintain the lease alive. The caller should iterate over
1373
+ this generator as long as they want the lease to be kept alive.
1374
+
1375
+ The stream will yield a response whenever the lease TTL is refreshed by
1376
+ the server (typically when TTL drops below a threshold).
1377
+
1378
+ This method automatically retries on connection errors, allowing recovery
1379
+ from temporary ETCD server restarts (as long as the lease hasn't expired).
1380
+
1381
+ :param lease_id: ID of the lease to refresh
1382
+ :param max_consecutive_errors: Maximum consecutive errors before giving up (default: 10)
1383
+ :param base_retry_delay: Base retry delay in seconds (default: 1.0)
1384
+ :param max_retry_delay: Maximum retry delay in seconds (default: 30.0)
1385
+
1386
+ Raises:
1387
+ grpc.RpcError: When connection errors exceed max retry attempts or
1388
+ lease is permanently not found
1389
+ """
1390
+ import logging
1391
+
1392
+ logger = logging.getLogger(__name__)
1393
+
1394
+ consecutive_errors = 0
1395
+
1396
+ while True:
1397
+ try:
1398
+ lease_stub = etcdrpc.LeaseStub(self.channel)
1399
+ keep_alive_request = etcdrpc.LeaseKeepAliveRequest(ID=lease_id)
1400
+ # Note: We use iter([request]) for each iteration. The gRPC stream
1401
+ # remains open, and etcd server will send keep-alive responses
1402
+ # periodically when the TTL approaches expiration.
1403
+ for response in lease_stub.LeaseKeepAlive(
1404
+ iter([keep_alive_request]),
1405
+ self.timeout,
1406
+ credentials=self.call_credentials,
1407
+ metadata=self.metadata,
1408
+ ):
1409
+ # Reset error count on successful response
1410
+ consecutive_errors = 0
1411
+ yield response
1412
+ except grpc.RpcError as exc:
1413
+ code = exc.code()
1414
+
1415
+ # Lease not found (expired or revoked) - permanent failure, stop retrying
1416
+ if code == grpc.StatusCode.NOT_FOUND:
1417
+ logger.debug(f"Lease {lease_id} not found, stopping keepalive")
1418
+ break
1419
+
1420
+ # Connection errors - retry with exponential backoff
1421
+ if code in (
1422
+ grpc.StatusCode.UNAVAILABLE,
1423
+ grpc.StatusCode.DEADLINE_EXCEEDED,
1424
+ grpc.StatusCode.INTERNAL,
1425
+ ):
1426
+ consecutive_errors += 1
1427
+
1428
+ if consecutive_errors >= max_consecutive_errors:
1429
+ logger.error(
1430
+ f"Lease {lease_id} keepalive failed after "
1431
+ f"{max_consecutive_errors} consecutive errors"
1432
+ )
1433
+ raise # Exceeded max retries, propagate the error
1434
+
1435
+ # Exponential backoff with jitter
1436
+ retry_delay = min(
1437
+ base_retry_delay * (2 ** (consecutive_errors - 1)),
1438
+ max_retry_delay,
1439
+ )
1440
+ jitter = random.uniform(0, retry_delay * 0.1)
1441
+ actual_delay = retry_delay + jitter
1442
+
1443
+ logger.warning(
1444
+ f"Lease {lease_id} keepalive connection error ({code}), "
1445
+ f"retrying in {actual_delay:.1f}s "
1446
+ f"(attempt {consecutive_errors}/{max_consecutive_errors})"
1447
+ )
1448
+
1449
+ time.sleep(actual_delay)
1450
+ continue # Retry the loop
1451
+
1452
+ # Other errors - propagate to let the decorator handle them
1453
+ raise
1454
+ except GeneratorExit:
1455
+ # Generator was closed externally (e.g., garbage collected or iteration stopped)
1456
+ break
1457
+
1458
+ @_sync_handle_errors
1459
+ @monitor_request("lease.get_info")
1460
+ @log_operation("lease.get_info")
1461
+ def get_lease_info(self, lease_id):
1462
+ # only available in etcd v3.1.0 and later
1463
+ ttl_request = etcdrpc.LeaseTimeToLiveRequest(ID=lease_id, keys=True)
1464
+ # Create lease stub directly
1465
+ lease_stub = etcdrpc.LeaseStub(self.channel)
1466
+ return lease_stub.LeaseTimeToLive(
1467
+ ttl_request,
1468
+ self.timeout,
1469
+ credentials=self.call_credentials,
1470
+ metadata=self.metadata,
1471
+ )
1472
+
1473
+ @_sync_handle_errors
1474
+ def lock(self, name, ttl=60):
1475
+ """
1476
+ Create a new lock.
1477
+
1478
+ :param name: name of the lock
1479
+ :type name: string or bytes
1480
+ :param ttl: length of time for the lock to live for in seconds. The
1481
+ lock will be released after this time elapses, unless
1482
+ refreshed
1483
+ :type ttl: int
1484
+ :returns: new lock
1485
+ :rtype: :class:`.Lock`
1486
+ """
1487
+ return locks.Lock(name, ttl=ttl, etcd_client=self)
1488
+
1489
+ @_sync_handle_errors
1490
+ def add_member(self, urls):
1491
+ """
1492
+ Add a member into the cluster.
1493
+
1494
+ :returns: new member
1495
+ :rtype: :class:`.Member`
1496
+ """
1497
+ member_add_request = etcdrpc.MemberAddRequest(peerURLs=urls)
1498
+
1499
+ # Create cluster stub directly
1500
+ cluster_stub = etcdrpc.ClusterStub(self.channel)
1501
+ member_add_response = cluster_stub.MemberAdd(
1502
+ member_add_request,
1503
+ self.timeout,
1504
+ credentials=self.call_credentials,
1505
+ metadata=self.metadata,
1506
+ )
1507
+
1508
+ member = member_add_response.member
1509
+ return etcd3.members.Member(
1510
+ member.ID, member.name, member.peerURLs, member.clientURLs, etcd_client=self
1511
+ )
1512
+
1513
+ @_sync_handle_errors
1514
+ def remove_member(self, member_id):
1515
+ """
1516
+ Remove an existing member from the cluster.
1517
+
1518
+ :param member_id: ID of the member to remove
1519
+ """
1520
+ member_rm_request = etcdrpc.MemberRemoveRequest(ID=member_id)
1521
+ # Create cluster stub directly
1522
+ cluster_stub = etcdrpc.ClusterStub(self.channel)
1523
+ cluster_stub.MemberRemove(
1524
+ member_rm_request,
1525
+ self.timeout,
1526
+ credentials=self.call_credentials,
1527
+ metadata=self.metadata,
1528
+ )
1529
+
1530
+ @_sync_handle_errors
1531
+ def update_member(self, member_id, peer_urls):
1532
+ """
1533
+ Update the configuration of an existing member in the cluster.
1534
+
1535
+ :param member_id: ID of the member to update
1536
+ :param peer_urls: new list of peer urls the member will use to
1537
+ communicate with the cluster
1538
+ """
1539
+ member_update_request = etcdrpc.MemberUpdateRequest(
1540
+ ID=member_id, peerURLs=peer_urls
1541
+ )
1542
+ # Create cluster stub directly
1543
+ cluster_stub = etcdrpc.ClusterStub(self.channel)
1544
+ cluster_stub.MemberUpdate(
1545
+ member_update_request,
1546
+ self.timeout,
1547
+ credentials=self.call_credentials,
1548
+ metadata=self.metadata,
1549
+ )
1550
+
1551
+ @property
1552
+ def members(self):
1553
+ """
1554
+ List of all members associated with the cluster.
1555
+
1556
+ :type: sequence of :class:`.Member`
1557
+
1558
+ """
1559
+ member_list_request = etcdrpc.MemberListRequest()
1560
+ # Create cluster stub directly
1561
+ cluster_stub = etcdrpc.ClusterStub(self.channel)
1562
+ member_list_response = cluster_stub.MemberList(
1563
+ member_list_request,
1564
+ self.timeout,
1565
+ credentials=self.call_credentials,
1566
+ metadata=self.metadata,
1567
+ )
1568
+
1569
+ for member in member_list_response.members:
1570
+ yield etcd3.members.Member(
1571
+ member.ID,
1572
+ member.name,
1573
+ member.peerURLs,
1574
+ member.clientURLs,
1575
+ etcd_client=self,
1576
+ )
1577
+
1578
+ @_sync_handle_errors
1579
+ @monitor_request("maintenance.compact")
1580
+ @log_operation("maintenance.compact")
1581
+ def compact(self, revision, physical=False):
1582
+ """
1583
+ Compact the event history in etcd up to a given revision.
1584
+
1585
+ All superseded keys with a revision less than the compaction revision
1586
+ will be removed.
1587
+
1588
+ :param revision: revision for the compaction operation
1589
+ :param physical: if set to True, the request will wait until the
1590
+ compaction is physically applied to the local database
1591
+ such that compacted entries are totally removed from
1592
+ the backend database
1593
+ """
1594
+ compact_request = etcdrpc.CompactionRequest(
1595
+ revision=revision, physical=physical
1596
+ )
1597
+ # Create KV stub directly
1598
+ kv_stub = etcdrpc.KVStub(self.channel)
1599
+ kv_stub.Compact(
1600
+ compact_request,
1601
+ self.timeout,
1602
+ credentials=self.call_credentials,
1603
+ metadata=self.metadata,
1604
+ )
1605
+
1606
+ @_sync_handle_errors
1607
+ @monitor_request("maintenance.defragment")
1608
+ @log_operation("maintenance.defragment")
1609
+ def defragment(self):
1610
+ """Defragment a member's backend database to recover storage space."""
1611
+ defrag_request = etcdrpc.DefragmentRequest()
1612
+ # Create maintenance stub directly
1613
+ maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
1614
+ maintenance_stub.Defragment(
1615
+ defrag_request,
1616
+ self.timeout,
1617
+ credentials=self.call_credentials,
1618
+ metadata=self.metadata,
1619
+ )
1620
+
1621
+ @_sync_handle_errors
1622
+ def hash(self):
1623
+ """
1624
+ Return the hash of the local KV state.
1625
+
1626
+ :returns: kv state hash
1627
+ :rtype: int
1628
+ """
1629
+ hash_request = etcdrpc.HashRequest()
1630
+ # Create maintenance stub directly
1631
+ maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
1632
+ return maintenance_stub.Hash(hash_request).hash
1633
+
1634
+ def _build_alarm_request(self, alarm_action, member_id, alarm_type):
1635
+ alarm_request = etcdrpc.AlarmRequest()
1636
+
1637
+ if alarm_action == "get":
1638
+ alarm_request.action = etcdrpc.AlarmRequest.GET
1639
+ elif alarm_action == "activate":
1640
+ alarm_request.action = etcdrpc.AlarmRequest.ACTIVATE
1641
+ elif alarm_action == "deactivate":
1642
+ alarm_request.action = etcdrpc.AlarmRequest.DEACTIVATE
1643
+ else:
1644
+ raise ValueError("Unknown alarm action: {}".format(alarm_action))
1645
+
1646
+ alarm_request.memberID = member_id
1647
+
1648
+ if alarm_type == "none":
1649
+ alarm_request.alarm = etcdrpc.NONE
1650
+ elif alarm_type == "no space":
1651
+ alarm_request.alarm = etcdrpc.NOSPACE
1652
+ else:
1653
+ raise ValueError("Unknown alarm type: {}".format(alarm_type))
1654
+
1655
+ return alarm_request
1656
+
1657
+ @_sync_handle_errors
1658
+ def create_alarm(self, member_id=0):
1659
+ """Create an alarm.
1660
+
1661
+ If no member id is given, the alarm is activated for all the
1662
+ members of the cluster. Only the `no space` alarm can be raised.
1663
+
1664
+ :param member_id: The cluster member id to create an alarm to.
1665
+ If 0, the alarm is created for all the members
1666
+ of the cluster.
1667
+ :returns: list of :class:`.Alarm`
1668
+ """
1669
+ alarm_request = self._build_alarm_request("activate", member_id, "no space")
1670
+ # Create maintenance stub directly
1671
+ maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
1672
+ alarm_response = maintenance_stub.Alarm(
1673
+ alarm_request,
1674
+ self.timeout,
1675
+ credentials=self.call_credentials,
1676
+ metadata=self.metadata,
1677
+ )
1678
+
1679
+ return [Alarm(alarm.alarm, alarm.memberID) for alarm in alarm_response.alarms]
1680
+
1681
+ @_sync_handle_errors
1682
+ def list_alarms(self, member_id=0, alarm_type="none"):
1683
+ """List the activated alarms.
1684
+
1685
+ :param member_id:
1686
+ :param alarm_type: The cluster member id to create an alarm to.
1687
+ If 0, the alarm is created for all the members
1688
+ of the cluster.
1689
+ :returns: sequence of :class:`.Alarm`
1690
+ """
1691
+ alarm_request = self._build_alarm_request("get", member_id, alarm_type)
1692
+ # Create maintenance stub directly
1693
+ maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
1694
+ alarm_response = maintenance_stub.Alarm(
1695
+ alarm_request,
1696
+ self.timeout,
1697
+ credentials=self.call_credentials,
1698
+ metadata=self.metadata,
1699
+ )
1700
+
1701
+ for alarm in alarm_response.alarms:
1702
+ yield Alarm(alarm.alarm, alarm.memberID)
1703
+
1704
+ @_sync_handle_errors
1705
+ def disarm_alarm(self, member_id=0):
1706
+ """Cancel an alarm.
1707
+
1708
+ :param member_id: The cluster member id to cancel an alarm.
1709
+ If 0, the alarm is canceled for all the members
1710
+ of the cluster.
1711
+ :returns: List of :class:`.Alarm`
1712
+ """
1713
+ alarm_request = self._build_alarm_request("deactivate", member_id, "no space")
1714
+ # Create maintenance stub directly
1715
+ maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
1716
+ alarm_response = maintenance_stub.Alarm(
1717
+ alarm_request,
1718
+ self.timeout,
1719
+ credentials=self.call_credentials,
1720
+ metadata=self.metadata,
1721
+ )
1722
+
1723
+ return [Alarm(alarm.alarm, alarm.memberID) for alarm in alarm_response.alarms]
1724
+
1725
+ @_sync_handle_errors
1726
+ def snapshot(self, file_obj):
1727
+ """Take a snapshot of the database.
1728
+
1729
+ :param file_obj: A file-like object to write the database contents in.
1730
+ """
1731
+ snapshot_request = etcdrpc.SnapshotRequest()
1732
+ # Create maintenance stub directly
1733
+ maintenance_stub = etcdrpc.MaintenanceStub(self.channel)
1734
+ snapshot_response = maintenance_stub.Snapshot(
1735
+ snapshot_request,
1736
+ self.timeout,
1737
+ credentials=self.call_credentials,
1738
+ metadata=self.metadata,
1739
+ )
1740
+
1741
+ for response in snapshot_response:
1742
+ file_obj.write(response.blob)
1743
+
1744
+ def batch_put(self, items, atomic=True):
1745
+ """Batch put multiple key-value pairs.
1746
+
1747
+ By default, this method executes all puts as a single atomic transaction,
1748
+ ensuring either all operations succeed or none do. If atomic=False,
1749
+ operations are executed sequentially.
1750
+
1751
+ :param items: Iterable of (key, value) tuples to put
1752
+ :type items: Iterable[tuple[str | bytes, str | bytes]]
1753
+ :param atomic: If True (default), execute as atomic transaction.
1754
+ :type atomic: bool
1755
+ :returns: Number of successful operations (atomic=True: either all or none)
1756
+ :rtype: int
1757
+ :raises BatchOperationError: If atomic=True and some operations fail
1758
+ """
1759
+ # Convert to list to allow multiple iterations
1760
+ items_list = list(items)
1761
+ if not items_list:
1762
+ return 0
1763
+
1764
+ if atomic:
1765
+ # Use atomic transaction for all operations
1766
+ success_ops = [transactions.Put(key, value) for key, value in items_list]
1767
+ # Use a dummy compare that always succeeds (create_revision > -1 is always true)
1768
+ compare = [transactions.Create(key) > -1 for key, _ in items_list]
1769
+
1770
+ try:
1771
+ success, _ = self.transaction(
1772
+ compare=compare,
1773
+ success=success_ops,
1774
+ failure=[],
1775
+ )
1776
+ if success:
1777
+ return len(items_list)
1778
+ else:
1779
+ raise exceptions.BatchOperationError(
1780
+ f"Atomic batch put failed for {len(items_list)} items"
1781
+ )
1782
+ except Exception as exc:
1783
+ if isinstance(exc, exceptions.BatchOperationError):
1784
+ raise
1785
+ raise exceptions.BatchOperationError(
1786
+ f"Batch put failed: {exc}"
1787
+ ) from exc
1788
+ else:
1789
+ # Non-atomic: sequential execution
1790
+ success_count = 0
1791
+ for key, value in items_list:
1792
+ try:
1793
+ self.put(key, value)
1794
+ success_count += 1
1795
+ except Exception:
1796
+ pass # Continue with next item
1797
+ return success_count
1798
+
1799
+ def batch_delete(self, keys, atomic=True):
1800
+ """Batch delete multiple keys.
1801
+
1802
+ By default, this method executes all deletes as a single atomic transaction,
1803
+ ensuring either all operations succeed or none do. If atomic=False,
1804
+ operations are executed sequentially.
1805
+
1806
+ :param keys: Iterable of keys to delete
1807
+ :type keys: Iterable[str | bytes]
1808
+ :param atomic: If True (default), execute as atomic transaction.
1809
+ :type atomic: bool
1810
+ :returns: Number of successful operations (atomic=True: either all or none)
1811
+ :rtype: int
1812
+ :raises BatchOperationError: If atomic=True and some operations fail
1813
+ """
1814
+ # Convert to list to allow multiple iterations
1815
+ keys_list = list(keys)
1816
+ if not keys_list:
1817
+ return 0
1818
+
1819
+ if atomic:
1820
+ # Use atomic transaction for all operations
1821
+ delete_ops = [transactions.Delete(key) for key in keys_list]
1822
+ # Use a dummy compare that always succeeds (create_revision > -1 is always true)
1823
+ compare = [transactions.Create(key) > -1 for key in keys_list]
1824
+
1825
+ try:
1826
+ success, _ = self.transaction(
1827
+ compare=compare,
1828
+ success=delete_ops,
1829
+ failure=[],
1830
+ )
1831
+ if success:
1832
+ return len(keys_list)
1833
+ else:
1834
+ raise exceptions.BatchOperationError(
1835
+ f"Atomic batch delete failed for {len(keys_list)} keys"
1836
+ )
1837
+ except Exception as exc:
1838
+ if isinstance(exc, exceptions.BatchOperationError):
1839
+ raise
1840
+ raise exceptions.BatchOperationError(
1841
+ f"Batch delete failed: {exc}"
1842
+ ) from exc
1843
+ else:
1844
+ # Non-atomic: sequential execution
1845
+ success_count = 0
1846
+ for key in keys_list:
1847
+ try:
1848
+ result = self.delete(key)
1849
+ if isinstance(result, bool):
1850
+ if result:
1851
+ success_count += 1
1852
+ else:
1853
+ success_count += result
1854
+ except Exception:
1855
+ pass # Continue with next item
1856
+ return success_count
1857
+
1858
+ def put_many(self, items, timeout=None):
1859
+ """Put multiple key-value pairs sequentially.
1860
+
1861
+ This is a simpler interface for batch puts that handles all
1862
+ operations sequentially.
1863
+
1864
+ :param items: Dict of {key: value} pairs or iterable of (key, value) tuples
1865
+ :type items: dict or Iterable[tuple]
1866
+ :param timeout: Optional timeout for each individual put operation
1867
+ :type timeout: float
1868
+ :returns: Tuple of (success_count, failed_keys)
1869
+ :rtype: tuple[int, list]
1870
+ """
1871
+ if isinstance(items, dict):
1872
+ items = list(items.items())
1873
+
1874
+ success_count = 0
1875
+ failed_keys = []
1876
+
1877
+ for key, value in items:
1878
+ try:
1879
+ self.put(key, value)
1880
+ success_count += 1
1881
+ except Exception as exc:
1882
+ failed_keys.append((key, exc))
1883
+
1884
+ return success_count, failed_keys
1885
+
1886
+
1887
+ class Etcd3Client(MultiEndpointEtcd3Client):
1888
+ """
1889
+ etcd v3 API client.
1890
+
1891
+ :param config: ClientConfig object with client configuration
1892
+ :param host: Host to connect to, 'localhost' if not specified
1893
+ :type host: str, optional
1894
+ :param port: Port to connect to on host, 2379 if not specified
1895
+ :type port: int, optional
1896
+ :param ca_cert: Filesystem path of etcd CA certificate
1897
+ :type ca_cert: str or os.PathLike, optional
1898
+ :param cert_key: Filesystem path of client key
1899
+ :type cert_key: str or os.PathLike, optional
1900
+ :param cert_cert: Filesystem path of client certificate
1901
+ :type cert_cert: str or os.PathLike, optional
1902
+ :param timeout: Timeout for all RPC in seconds
1903
+ :type timeout: int or float, optional
1904
+ :param user: Username for authentication
1905
+ :type user: str, optional
1906
+ :param password: Password for authentication
1907
+ :type password: str, optional
1908
+ :param dict grpc_options: Additional gRPC options
1909
+ :type grpc_options: dict, optional
1910
+ """
1911
+
1912
+ def __init__(
1913
+ self,
1914
+ config=None,
1915
+ host="localhost",
1916
+ port=2379,
1917
+ ca_cert=None,
1918
+ cert_key=None,
1919
+ cert_cert=None,
1920
+ timeout=None,
1921
+ user=None,
1922
+ password=None,
1923
+ grpc_options=None,
1924
+ ):
1925
+ # Use config object if provided
1926
+ if config is not None:
1927
+ host = config.host
1928
+ port = config.port
1929
+ ca_cert = config.ca_cert
1930
+ cert_key = config.cert_key
1931
+ cert_cert = config.cert_cert
1932
+ timeout = config.timeout
1933
+ user = config.user
1934
+ password = config.password
1935
+ grpc_options = config.grpc_options
1936
+
1937
+ # Step 1: verify credentials
1938
+ cert_params = [c is not None for c in (cert_cert, cert_key)]
1939
+ if ca_cert is not None:
1940
+ if all(cert_params):
1941
+ credentials = get_secure_creds(ca_cert, cert_key, cert_cert)
1942
+ self.uses_secure_channel = True
1943
+ elif any(cert_params):
1944
+ # some of the cert parameters are set
1945
+ raise ValueError(
1946
+ "to use a secure channel ca_cert is required by itself, "
1947
+ "or cert_cert and cert_key must both be specified."
1948
+ )
1949
+ else:
1950
+ credentials = get_secure_creds(ca_cert, None, None)
1951
+ self.uses_secure_channel = True
1952
+ else:
1953
+ self.uses_secure_channel = False
1954
+ credentials = None
1955
+
1956
+ # Step 2: create Endpoint
1957
+ ep = Endpoint(
1958
+ host,
1959
+ port,
1960
+ secure=self.uses_secure_channel,
1961
+ creds=credentials,
1962
+ opts=grpc_options,
1963
+ )
1964
+
1965
+ super(Etcd3Client, self).__init__(
1966
+ endpoints=[ep], timeout=timeout, user=user, password=password
1967
+ )
1968
+
1969
+
1970
+ def client(
1971
+ config=None,
1972
+ host="localhost",
1973
+ port=2379,
1974
+ ca_cert=None,
1975
+ cert_key=None,
1976
+ cert_cert=None,
1977
+ timeout=None,
1978
+ user=None,
1979
+ password=None,
1980
+ grpc_options=None,
1981
+ ):
1982
+ """Return an instance of an Etcd3Client.
1983
+
1984
+ :param config: ClientConfig object with client configuration
1985
+ :param host: etcd host (ignored if config is provided)
1986
+ :type host: str, optional
1987
+ :param port: etcd port (ignored if config is provided)
1988
+ :type port: int, optional
1989
+ :param ca_cert: CA certificate path (ignored if config is provided)
1990
+ :type ca_cert: str, optional
1991
+ :param cert_key: client certificate key path (ignored if config is provided)
1992
+ :type cert_key: str, optional
1993
+ :param cert_cert: client certificate path (ignored if config is provided)
1994
+ :type cert_cert: str, optional
1995
+ :param timeout: request timeout in seconds (ignored if config is provided)
1996
+ :type timeout: int or float, optional
1997
+ :param user: username for authentication (ignored if config is provided)
1998
+ :type user: str, optional
1999
+ :param password: password for authentication (ignored if config is provided)
2000
+ :type password: str, optional
2001
+ :param grpc_options: additional gRPC options (ignored if config is provided)
2002
+ :type grpc_options: dict[str, Any], optional
2003
+ :return: Etcd3Client instance
2004
+ """
2005
+ # Auto-initialize observability
2006
+ from etcd3.observability import auto_init
2007
+
2008
+ auto_init()
2009
+
2010
+ return Etcd3Client(
2011
+ config=config,
2012
+ host=host,
2013
+ port=port,
2014
+ ca_cert=ca_cert,
2015
+ cert_key=cert_key,
2016
+ cert_cert=cert_cert,
2017
+ timeout=timeout,
2018
+ user=user,
2019
+ password=password,
2020
+ grpc_options=grpc_options,
2021
+ )