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/endpoint.py ADDED
@@ -0,0 +1,79 @@
1
+ """Base endpoint implementation for etcd3 client.
2
+
3
+ This module provides the BaseEndpoint class that contains shared logic
4
+ for both synchronous and asynchronous endpoints.
5
+ """
6
+
7
+ import grpc
8
+ import time
9
+
10
+
11
+ class BaseEndpoint:
12
+ """Base endpoint with shared logic.
13
+
14
+ :param str host: Endpoint host
15
+ :param int port: Endpoint port
16
+ :param bool secure: Use secure channel, default True
17
+ :param creds: Credentials to use for secure channel, required if
18
+ secure=True
19
+ :type creds: grpc.ChannelCredentials, optional
20
+ :param time_retry: Seconds to wait before retrying this endpoint after
21
+ failure, default 300.0
22
+ :type time_retry: int or float
23
+ :param opts: Additional gRPC options
24
+ :type opts: dict, optional
25
+ """
26
+
27
+ def __init__(
28
+ self, host, port, secure=True, creds=None, time_retry=300.0, opts=None
29
+ ):
30
+ self.host = host
31
+ self.netloc = f"{host}:{port}"
32
+ self.secure = secure
33
+ self.protocol = "https" if secure else "http"
34
+ if self.secure and creds is None:
35
+ raise ValueError("Please set TLS credentials for secure connections")
36
+ self.credentials = creds
37
+ self.time_retry = time_retry
38
+ self.opts = opts
39
+ self.in_use = False
40
+ self.last_failed = 0
41
+ self.channel = None # To be set by subclasses
42
+
43
+ def fail(self):
44
+ """Transition the endpoint to a failed state."""
45
+ self.in_use = False
46
+ self.last_failed = time.time()
47
+
48
+ def use(self):
49
+ """Transition the endpoint to an active state."""
50
+ if self.is_failed():
51
+ raise ValueError("Trying to use a failed node")
52
+ self.in_use = True
53
+ self.last_failed = 0
54
+ return self.channel
55
+
56
+ def is_failed(self):
57
+ """Check if the current endpoint is failed."""
58
+ return (time.time() - self.last_failed) < self.time_retry
59
+
60
+ def is_healthy(self):
61
+ """Check if the endpoint is healthy.
62
+
63
+ Only READY state is considered healthy. CONNECTING state is excluded
64
+ to ensure the connection is fully established before use.
65
+
66
+ :returns: True if the endpoint is healthy (READY state)
67
+ :rtype: bool
68
+ """
69
+ if self.channel is None:
70
+ return False
71
+ try:
72
+ state = self.channel.get_state()
73
+ # Only READY is considered healthy, not CONNECTING
74
+ return state == grpc.ChannelConnectivity.READY
75
+ except Exception:
76
+ return False
77
+
78
+ def __str__(self):
79
+ return f"Endpoint({self.protocol}://{self.netloc})"
etcd3/events.py ADDED
@@ -0,0 +1,40 @@
1
+ class Event(object):
2
+ def __init__(self, event):
3
+ self.key = event.kv.key
4
+ self._event = event
5
+
6
+ def __getattr__(self, name):
7
+ if name.startswith("prev_"):
8
+ return getattr(self._event.prev_kv, name[5:])
9
+ return getattr(self._event.kv, name)
10
+
11
+ def __str__(self):
12
+ return "{type} key={key} value={value}".format(
13
+ type=self.__class__, key=self.key, value=self.value
14
+ )
15
+
16
+
17
+ class PutEvent(Event):
18
+ pass
19
+
20
+
21
+ class DeleteEvent(Event):
22
+ pass
23
+
24
+
25
+ def new_event(event):
26
+ """
27
+ Wrap a raw gRPC event in a friendlier containing class.
28
+
29
+ This picks the appropriate class from one of PutEvent or DeleteEvent and
30
+ returns a new instance.
31
+ """
32
+ op_name = event.EventType.DESCRIPTOR.values_by_number[event.type].name
33
+ if op_name == "PUT":
34
+ cls = PutEvent
35
+ elif op_name == "DELETE":
36
+ cls = DeleteEvent
37
+ else:
38
+ raise Exception("Invalid op_name")
39
+
40
+ return cls(event)
etcd3/exceptions.py ADDED
@@ -0,0 +1,47 @@
1
+ class Etcd3Exception(Exception):
2
+ pass
3
+
4
+
5
+ class WatchTimedOut(Etcd3Exception):
6
+ pass
7
+
8
+
9
+ class InternalServerError(Etcd3Exception):
10
+ pass
11
+
12
+
13
+ class ConnectionFailedError(Etcd3Exception):
14
+ def __str__(self):
15
+ return "etcd connection failed"
16
+
17
+
18
+ class ConnectionTimeoutError(Etcd3Exception):
19
+ def __str__(self):
20
+ return "etcd connection timeout"
21
+
22
+
23
+ class PreconditionFailedError(Etcd3Exception):
24
+ pass
25
+
26
+
27
+ class RevisionCompactedError(Etcd3Exception):
28
+ def __init__(self, compacted_revision):
29
+ self.compacted_revision = compacted_revision
30
+ super(RevisionCompactedError, self).__init__()
31
+
32
+ def __str__(self):
33
+ return "etcd revision {} has been compacted".format(self.compacted_revision)
34
+
35
+
36
+ class NoServerAvailableError(Etcd3Exception):
37
+ pass
38
+
39
+
40
+ class LockReleaseError(Etcd3Exception):
41
+ """Raised when a lock release operation fails."""
42
+ pass
43
+
44
+
45
+ class BatchOperationError(Etcd3Exception):
46
+ """Raised when a batch operation fails."""
47
+ pass
@@ -0,0 +1,18 @@
1
+ """Exporters for etcd3 observability.
2
+
3
+ This package provides various exporters for metrics and traces.
4
+ """
5
+
6
+ from etcd3.exporters.prometheus import (
7
+ EtcdPrometheusExporter,
8
+ get_exporter,
9
+ init_exporter,
10
+ is_prometheus_available,
11
+ )
12
+
13
+ __all__ = [
14
+ "EtcdPrometheusExporter",
15
+ "get_exporter",
16
+ "init_exporter",
17
+ "is_prometheus_available",
18
+ ]
@@ -0,0 +1,365 @@
1
+ """Prometheus metrics exporter for etcd3 client.
2
+
3
+ This module provides Prometheus-compatible metrics export for the etcd3 client,
4
+ supporting various metric types including counters, gauges, and histograms.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Optional
10
+ import logging
11
+
12
+ if TYPE_CHECKING:
13
+ from prometheus_client import CollectorRegistry
14
+
15
+ try:
16
+ from prometheus_client import (
17
+ Counter,
18
+ Gauge,
19
+ Histogram,
20
+ CollectorRegistry,
21
+ generate_latest,
22
+ )
23
+
24
+ PROMETHEUS_AVAILABLE = True
25
+ except ImportError:
26
+ PROMETHEUS_AVAILABLE = False
27
+
28
+
29
+ logger = logging.getLogger("etcd3.exporters")
30
+
31
+
32
+ class EtcdPrometheusExporter:
33
+ """Prometheus metrics exporter for etcd3 client.
34
+
35
+ Provides Prometheus-compatible metrics with labels for operation,
36
+ endpoint, and status dimensions.
37
+
38
+ Example:
39
+ >>> exporter = EtcdPrometheusExporter()
40
+ >>> exporter.record_request("kv.get", endpoint="localhost:2379", duration=0.001)
41
+ >>> print(exporter.metrics()) # Get Prometheus format output
42
+ """
43
+
44
+ def __init__(self, registry: Optional[CollectorRegistry] = None):
45
+ """Initialize Prometheus exporter.
46
+
47
+ Args:
48
+ registry: Optional Prometheus registry. Uses default if None.
49
+ """
50
+ if not PROMETHEUS_AVAILABLE:
51
+ raise ImportError(
52
+ "prometheus_client is required for metrics export. "
53
+ "Install with: pip install prometheus-client"
54
+ )
55
+
56
+ self._registry = registry or CollectorRegistry()
57
+
58
+ # Request metrics
59
+ self._request_counter = Counter(
60
+ "etcd3_requests_total",
61
+ "Total number of requests",
62
+ ["operation", "endpoint", "status"],
63
+ registry=self._registry,
64
+ )
65
+
66
+ self._request_duration = Histogram(
67
+ "etcd3_request_duration_seconds",
68
+ "Request duration in seconds",
69
+ ["operation", "endpoint"],
70
+ buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
71
+ registry=self._registry,
72
+ )
73
+
74
+ # Error metrics
75
+ self._error_counter = Counter(
76
+ "etcd3_errors_total",
77
+ "Total number of errors",
78
+ ["operation", "endpoint", "error_type"],
79
+ registry=self._registry,
80
+ )
81
+
82
+ # Retry metrics
83
+ self._retry_counter = Counter(
84
+ "etcd3_retries_total",
85
+ "Total number of retries",
86
+ ["operation", "endpoint"],
87
+ registry=self._registry,
88
+ )
89
+
90
+ # Connection metrics
91
+ self._connection_gauge = Gauge(
92
+ "etcd3_connections",
93
+ "Number of active connections",
94
+ ["endpoint", "state"],
95
+ registry=self._registry,
96
+ )
97
+
98
+ def record_request(
99
+ self,
100
+ operation: str,
101
+ endpoint: str,
102
+ duration: float,
103
+ status: str = "success",
104
+ ):
105
+ """Record a request metric.
106
+
107
+ Args:
108
+ operation: Operation name (e.g., "kv.get", "lease.grant")
109
+ endpoint: Endpoint address
110
+ duration: Request duration in seconds
111
+ status: Request status ("success" or "error")
112
+ """
113
+ status_label = "success" if status in ("success", "ok", 200, 0) else "error"
114
+
115
+ self._request_counter.labels(
116
+ operation=operation,
117
+ endpoint=endpoint,
118
+ status=status_label,
119
+ ).inc()
120
+
121
+ self._request_duration.labels(
122
+ operation=operation,
123
+ endpoint=endpoint,
124
+ ).observe(duration)
125
+
126
+ def record_error(
127
+ self,
128
+ operation: str,
129
+ endpoint: str,
130
+ error_type: str,
131
+ ):
132
+ """Record an error metric.
133
+
134
+ Args:
135
+ operation: Operation name
136
+ endpoint: Endpoint address
137
+ error_type: Error type/class name
138
+ """
139
+ self._error_counter.labels(
140
+ operation=operation,
141
+ endpoint=endpoint,
142
+ error_type=error_type,
143
+ ).inc()
144
+
145
+ def record_retry(
146
+ self,
147
+ operation: str,
148
+ endpoint: str,
149
+ ):
150
+ """Record a retry metric.
151
+
152
+ Args:
153
+ operation: Operation name
154
+ endpoint: Endpoint address
155
+ """
156
+ self._retry_counter.labels(
157
+ operation=operation,
158
+ endpoint=endpoint,
159
+ ).inc()
160
+
161
+ def set_connection_state(
162
+ self,
163
+ endpoint: str,
164
+ state: str,
165
+ ):
166
+ """Set connection state gauge.
167
+
168
+ Args:
169
+ endpoint: Endpoint address
170
+ state: Connection state ("active", "idle", "failed")
171
+ """
172
+ self._connection_gauge.labels(
173
+ endpoint=endpoint,
174
+ state=state,
175
+ ).set(1)
176
+
177
+ def unset_connection(self, endpoint: str):
178
+ """Unset connection for an endpoint.
179
+
180
+ Args:
181
+ endpoint: Endpoint address
182
+ """
183
+ for state in ("active", "idle", "failed"):
184
+ try:
185
+ self._connection_gauge.labels(
186
+ endpoint=endpoint,
187
+ state=state,
188
+ ).dec(1)
189
+ except ValueError:
190
+ pass # Value already 0
191
+
192
+ def record_endpoint_failure(self, endpoint: str):
193
+ """Record an endpoint failure."""
194
+ self.set_connection_state(endpoint, "failed")
195
+
196
+ def record_endpoint_usage(self, endpoint: str):
197
+ """Record an endpoint usage."""
198
+ self.record_request("endpoint.usage", endpoint, 0, "success")
199
+
200
+ def record_endpoint_recovery(self, endpoint: str):
201
+ """Record an endpoint recovery."""
202
+ self.set_connection_state(endpoint, "active")
203
+
204
+ def metrics(self) -> bytes:
205
+ """Get metrics in Prometheus format.
206
+
207
+ Returns:
208
+ Prometheus metrics text format
209
+ """
210
+ return generate_latest(self._registry)
211
+
212
+ def reset(self):
213
+ """Reset all metrics."""
214
+ logger.warning("Reset not fully supported in Prometheus exporter")
215
+
216
+
217
+ # Global exporter instance
218
+ _exporter: Optional[EtcdPrometheusExporter] = None
219
+
220
+
221
+ def get_exporter() -> Optional[EtcdPrometheusExporter]:
222
+ """Get the global Prometheus exporter instance.
223
+
224
+ Returns:
225
+ Global EtcdPrometheusExporter instance or None if not available
226
+ """
227
+ global _exporter
228
+ if _exporter is None and PROMETHEUS_AVAILABLE:
229
+ try:
230
+ _exporter = EtcdPrometheusExporter()
231
+ except Exception as e:
232
+ logger.warning(f"Failed to create Prometheus exporter: {e}")
233
+ return None
234
+ return _exporter
235
+
236
+
237
+ def init_exporter(
238
+ registry: Optional[CollectorRegistry] = None,
239
+ ) -> EtcdPrometheusExporter:
240
+ """Initialize the global Prometheus exporter.
241
+
242
+ Args:
243
+ registry: Optional Prometheus registry
244
+
245
+ Returns:
246
+ Initialized EtcdPrometheusExporter instance
247
+ """
248
+ global _exporter
249
+ _exporter = EtcdPrometheusExporter(registry)
250
+ return _exporter
251
+
252
+
253
+ def is_prometheus_available() -> bool:
254
+ """Check if Prometheus client is available.
255
+
256
+ Returns:
257
+ True if prometheus_client is installed
258
+ """
259
+ return PROMETHEUS_AVAILABLE
260
+
261
+
262
+ class _NoOpMetrics:
263
+ """No-op metrics collector when Prometheus is not available."""
264
+
265
+ def record_request(
266
+ self, operation: str, endpoint: str, duration: float, status: str = "success"
267
+ ):
268
+ pass
269
+
270
+ def record_error(self, operation: str, endpoint: str, error_type: str):
271
+ pass
272
+
273
+ def record_retry(self, operation: str, endpoint: str):
274
+ pass
275
+
276
+ def record_endpoint_failure(self, endpoint: str):
277
+ pass
278
+
279
+ def record_endpoint_usage(self, endpoint: str):
280
+ pass
281
+
282
+ def record_endpoint_recovery(self, endpoint: str):
283
+ pass
284
+
285
+ def set_connection_state(self, endpoint: str, state: str):
286
+ pass
287
+
288
+ def unset_connection(self, endpoint: str):
289
+ pass
290
+
291
+ def metrics(self) -> bytes:
292
+ return b""
293
+
294
+
295
+ def get_metrics_interface():
296
+ """Get the metrics interface.
297
+
298
+ Returns:
299
+ EtcdPrometheusExporter if available, otherwise NoOpMetrics
300
+ """
301
+ if PROMETHEUS_AVAILABLE:
302
+ exporter = get_exporter()
303
+ if exporter is not None:
304
+ return exporter
305
+ return _NoOpMetrics()
306
+
307
+
308
+ def monitor_request(operation: str, is_async: bool = False):
309
+ """Decorator factory to monitor request latency and errors.
310
+
311
+ Args:
312
+ operation: Name of the operation
313
+ is_async: Whether the decorated function is async
314
+
315
+ Returns:
316
+ Decorator function
317
+ """
318
+ import time
319
+
320
+ def decorator(func):
321
+ if is_async:
322
+
323
+ async def async_wrapper(*args, **kwargs):
324
+ start_time = time.time()
325
+ try:
326
+ result = await func(*args, **kwargs)
327
+ latency = time.time() - start_time
328
+ get_metrics_interface().record_request(
329
+ operation, "unknown", latency, "success"
330
+ )
331
+ return result
332
+ except Exception as e:
333
+ latency = time.time() - start_time
334
+ get_metrics_interface().record_request(
335
+ operation, "unknown", latency, "error"
336
+ )
337
+ get_metrics_interface().record_error(
338
+ operation, "unknown", type(e).__name__
339
+ )
340
+ raise
341
+
342
+ return async_wrapper
343
+
344
+ def sync_wrapper(*args, **kwargs):
345
+ start_time = time.time()
346
+ try:
347
+ result = func(*args, **kwargs)
348
+ latency = time.time() - start_time
349
+ get_metrics_interface().record_request(
350
+ operation, "unknown", latency, "success"
351
+ )
352
+ return result
353
+ except Exception as e:
354
+ latency = time.time() - start_time
355
+ get_metrics_interface().record_request(
356
+ operation, "unknown", latency, "error"
357
+ )
358
+ get_metrics_interface().record_error(
359
+ operation, "unknown", type(e).__name__
360
+ )
361
+ raise
362
+
363
+ return sync_wrapper
364
+
365
+ return decorator
etcd3/leases.py ADDED
@@ -0,0 +1,106 @@
1
+ class Lease(object):
2
+ """
3
+ A lease.
4
+
5
+ :ivar id: ID of the lease
6
+ :ivar ttl: time to live for this lease
7
+ """
8
+
9
+ def __init__(self, lease_id, ttl, etcd_client=None):
10
+ if ttl <= 0:
11
+ raise ValueError("ttl must be a positive integer")
12
+ self.id = lease_id
13
+ self.ttl = ttl
14
+
15
+ self.etcd_client = etcd_client
16
+
17
+ def _get_lease_info(self):
18
+ return self.etcd_client.get_lease_info(self.id)
19
+
20
+ def revoke(self):
21
+ """Revoke this lease."""
22
+ self.etcd_client.revoke_lease(self.id)
23
+
24
+ def refresh(self):
25
+ """Refresh the time to live for this lease.
26
+
27
+ Returns a generator that yields keep-alive responses.
28
+ The caller should iterate over this generator to keep the lease alive.
29
+
30
+ Example:
31
+ for response in lease.refresh():
32
+ # Lease is kept alive while iterating
33
+ pass
34
+ """
35
+ return self.etcd_client.refresh_lease(self.id)
36
+
37
+ @property
38
+ def remaining_ttl(self):
39
+ return self._get_lease_info().TTL
40
+
41
+ @property
42
+ def granted_ttl(self):
43
+ return self._get_lease_info().grantedTTL
44
+
45
+ @property
46
+ def keys(self):
47
+ return self._get_lease_info().keys
48
+
49
+
50
+ class AsyncLease(object):
51
+ """
52
+ An asynchronous lease.
53
+
54
+ :ivar id: ID of the lease
55
+ :ivar ttl: time to live for this lease
56
+ """
57
+
58
+ def __init__(self, lease_id, ttl, etcd_client=None):
59
+ if ttl <= 0:
60
+ raise ValueError("ttl must be a positive integer")
61
+ self.id = lease_id
62
+ self.ttl = ttl
63
+
64
+ self.etcd_client = etcd_client
65
+
66
+ async def _get_lease_info(self):
67
+ """Get lease information from etcd."""
68
+ return await self.etcd_client.get_lease_info(self.id)
69
+
70
+ async def revoke(self):
71
+ """Revoke this lease."""
72
+ await self.etcd_client.revoke_lease(self.id)
73
+
74
+ def refresh(self):
75
+ """Refresh the time to live for this lease.
76
+
77
+ Returns an async generator that yields keep-alive responses.
78
+ """
79
+ return self.etcd_client.refresh_lease(self.id)
80
+
81
+ async def get_remaining_ttl(self):
82
+ """Get the remaining time to live for this lease.
83
+
84
+ :returns: remaining TTL in seconds
85
+ :rtype: int
86
+ """
87
+ info = await self._get_lease_info()
88
+ return info.TTL
89
+
90
+ async def get_granted_ttl(self):
91
+ """Get the granted time to live for this lease.
92
+
93
+ :returns: granted TTL in seconds
94
+ :rtype: int
95
+ """
96
+ info = await self._get_lease_info()
97
+ return info.grantedTTL
98
+
99
+ async def get_keys(self):
100
+ """Get the keys attached to this lease.
101
+
102
+ :returns: list of keys attached to this lease
103
+ :rtype: list
104
+ """
105
+ info = await self._get_lease_info()
106
+ return list(info.keys)