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/log.py ADDED
@@ -0,0 +1,146 @@
1
+ """Logging utilities for etcd3 client.
2
+
3
+ This module provides logging functionality for etcd3 operations,
4
+ including decorators for logging key operations and trace context integration.
5
+ """
6
+
7
+ import functools
8
+ import logging
9
+ import os
10
+ from contextvars import ContextVar
11
+ from typing import Optional
12
+
13
+ from etcd3.trace import get_current_span, is_tracing_available
14
+
15
+ ENV_PREFIX = "ETCD_OBS_"
16
+ ENV_LOG_LEVEL = f"{ENV_PREFIX}LOG_LEVEL"
17
+
18
+ _log_operation: ContextVar[Optional[str]] = ContextVar("log_operation", default=None)
19
+
20
+ _log = logging.getLogger("etcd3")
21
+ _log.addHandler(logging.NullHandler())
22
+
23
+ _env_level = os.environ.get(ENV_LOG_LEVEL)
24
+ if _env_level:
25
+ try:
26
+ _log.setLevel(getattr(logging, _env_level.upper(), logging.DEBUG))
27
+ except (AttributeError, ValueError):
28
+ pass
29
+
30
+
31
+ def log_operation(operation_name: str):
32
+ """Decorator to log operation start and completion for sync functions.
33
+
34
+ Args:
35
+ operation_name: Name of the operation for logging
36
+
37
+ Returns:
38
+ Decorator function
39
+ """
40
+
41
+ def decorator(func):
42
+ @functools.wraps(func)
43
+ def wrapper(*args, **kwargs):
44
+ _log_operation.set(operation_name)
45
+ if _log.isEnabledFor(logging.DEBUG):
46
+ _log.debug("[etcd3] Starting operation: %s", operation_name)
47
+ try:
48
+ result = func(*args, **kwargs)
49
+ if _log.isEnabledFor(logging.DEBUG):
50
+ _log.debug("[etcd3] Completed operation: %s", operation_name)
51
+ return result
52
+ except Exception as e:
53
+ _log.error("[etcd3] Operation failed: %s, error: %s", operation_name, e)
54
+ raise
55
+
56
+ return wrapper
57
+
58
+ return decorator
59
+
60
+
61
+ def log_operation_async(operation_name: str):
62
+ """Decorator to log operation start and completion for async functions.
63
+
64
+ Args:
65
+ operation_name: Name of the operation for logging
66
+
67
+ Returns:
68
+ Decorator function
69
+ """
70
+
71
+ def decorator(func):
72
+ @functools.wraps(func)
73
+ async def wrapper(*args, **kwargs):
74
+ _log_operation.set(operation_name)
75
+ if _log.isEnabledFor(logging.DEBUG):
76
+ _log.debug("[etcd3] Starting operation: %s", operation_name)
77
+ try:
78
+ result = await func(*args, **kwargs)
79
+ if _log.isEnabledFor(logging.DEBUG):
80
+ _log.debug("[etcd3] Completed operation: %s", operation_name)
81
+ return result
82
+ except Exception as e:
83
+ _log.error("[etcd3] Operation failed: %s, error: %s", operation_name, e)
84
+ raise
85
+
86
+ return wrapper
87
+
88
+ return decorator
89
+
90
+
91
+ def get_logger(operation: Optional[str] = None) -> logging.Logger:
92
+ """Get the etcd3 logger instance, optionally with operation context.
93
+
94
+ Args:
95
+ operation: Optional operation name to set in context
96
+
97
+ Returns:
98
+ Logger instance
99
+ """
100
+ if operation:
101
+ _log_operation.set(operation)
102
+ return _log
103
+
104
+
105
+ def set_log_level(level: str):
106
+ """Set the log level for etcd3 logger.
107
+
108
+ This can also be configured via the ETCD3_LOG_LEVEL environment variable.
109
+
110
+ Args:
111
+ level: Log level as string ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
112
+ """
113
+ _log.setLevel(getattr(logging, level.upper(), logging.DEBUG))
114
+
115
+
116
+ def get_trace_context() -> tuple[Optional[str], Optional[str]]:
117
+ """Get trace context (trace_id, span_id) from current span.
118
+
119
+ Returns:
120
+ Tuple of (trace_id, span_id), or (None, None) if not available
121
+ """
122
+ if not is_tracing_available():
123
+ return None, None
124
+
125
+ span = get_current_span()
126
+ if span is None:
127
+ return None, None
128
+
129
+ trace_id = span.get_span_context().trace_id
130
+ span_id = span.get_span_context().span_id
131
+
132
+ trace_id_hex = format(trace_id, "032x") if trace_id else None
133
+ span_id_hex = format(span_id, "016x") if span_id else None
134
+
135
+ return trace_id_hex, span_id_hex
136
+
137
+
138
+ class TraceContextLogRecord(logging.LogRecord):
139
+ """LogRecord subclass that includes trace context."""
140
+
141
+ def __init__(self, *args, **kwargs):
142
+ super().__init__(*args, **kwargs)
143
+ trace_id, span_id = get_trace_context()
144
+ self.trace_id = trace_id
145
+ self.span_id = span_id
146
+ self.operation = _log_operation.get()
@@ -0,0 +1,228 @@
1
+ """Logging formatters for etcd3 client.
2
+
3
+ This module provides structured logging formatters including JSON and colored
4
+ console output, with support for trace context integration.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import sys
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Dict, Optional
12
+
13
+
14
+ class JSONFormatter(logging.Formatter):
15
+ """JSON structured log formatter.
16
+
17
+ Outputs logs as JSON objects with standardized fields for easy ingestion
18
+ into log aggregation systems like ELK, Loki, or cloud logging services.
19
+
20
+ Example output:
21
+ {
22
+ "timestamp": "2024-01-15T08:30:00.123456+00:00",
23
+ "level": "INFO",
24
+ "logger": "etcd3",
25
+ "message": "Completed operation: kv.put",
26
+ "operation": "kv.put",
27
+ "trace_id": "abcd1234...",
28
+ "span_id": "efgh5678...",
29
+ "source": {
30
+ "file": "etcd3/async_client_extensions.py",
31
+ "line": 177,
32
+ "function": "put"
33
+ }
34
+ }
35
+ """
36
+
37
+ def __init__(self, indent: Optional[int] = None):
38
+ """Initialize JSON formatter.
39
+
40
+ :param indent: Indentation for pretty-printing (None for compact)
41
+ """
42
+ super().__init__()
43
+ self.indent = indent
44
+
45
+ def format(self, record: logging.LogRecord) -> str:
46
+ """Format log record as JSON.
47
+
48
+ :param record: Log record to format
49
+ :returns: JSON string
50
+ """
51
+ # Build base log data
52
+ log_data: Dict[str, Any] = {
53
+ "timestamp": datetime.now(timezone.utc).isoformat(),
54
+ "level": record.levelname,
55
+ "logger": record.name,
56
+ "message": record.getMessage(),
57
+ }
58
+
59
+ # Add extra fields if present
60
+ if hasattr(record, "operation") and record.operation:
61
+ log_data["operation"] = record.operation
62
+
63
+ if hasattr(record, "trace_id") and record.trace_id:
64
+ log_data["trace_id"] = record.trace_id
65
+
66
+ if hasattr(record, "span_id") and record.span_id:
67
+ log_data["span_id"] = record.span_id
68
+
69
+ if hasattr(record, "attributes") and record.attributes:
70
+ log_data["attributes"] = record.attributes
71
+
72
+ # Add exception info if present
73
+ if record.exc_info:
74
+ log_data["error"] = self.formatException(record.exc_info)
75
+
76
+ # Add source location
77
+ log_data["source"] = {
78
+ "file": record.pathname,
79
+ "line": record.lineno,
80
+ "function": record.funcName,
81
+ }
82
+
83
+ return json.dumps(log_data, indent=self.indent, default=str)
84
+
85
+
86
+ class ColoredTextFormatter(logging.Formatter):
87
+ """Colored text formatter for console output.
88
+
89
+ Adds ANSI color codes to log output for better readability in terminals.
90
+ Automatically detects if the output is a TTY and disables colors if not.
91
+ """
92
+
93
+ # ANSI color codes
94
+ COLORS = {
95
+ "DEBUG": "\033[36m", # Cyan
96
+ "INFO": "\033[32m", # Green
97
+ "WARNING": "\033[33m", # Yellow
98
+ "ERROR": "\033[31m", # Red
99
+ "CRITICAL": "\033[35m", # Magenta
100
+ "RESET": "\033[0m",
101
+ "BOLD": "\033[1m",
102
+ "DIM": "\033[2m",
103
+ }
104
+
105
+ def __init__(self, include_trace: bool = True, fmt: Optional[str] = None):
106
+ """Initialize colored formatter.
107
+
108
+ :param include_trace: Whether to include trace context in output
109
+ :param fmt: Optional format string override
110
+ """
111
+ super().__init__()
112
+ self.include_trace = include_trace
113
+ self.supports_color = sys.stderr.isatty()
114
+ self.fmt = (
115
+ fmt
116
+ or "%(asctime)s %(levelcolor)s[%(levelname)s]%(reset)s %(name)s: %(message)s"
117
+ )
118
+
119
+ def _colorize(self, text: str, color: str) -> str:
120
+ """Wrap text in color codes if supported.
121
+
122
+ :param text: Text to colorize
123
+ :param color: Color name
124
+ :returns: Colorized or plain text
125
+ """
126
+ if not self.supports_color:
127
+ return text
128
+ reset = self.COLORS["RESET"]
129
+ color_code = self.COLORS.get(color, "")
130
+ return f"{color_code}{text}{reset}"
131
+
132
+ def format(self, record: logging.LogRecord) -> str:
133
+ """Format log record with colors.
134
+
135
+ :param record: Log record to format
136
+ :returns: Formatted string
137
+ """
138
+ # Format timestamp
139
+ record.asctime = self.formatTime(record)
140
+
141
+ # Build main message
142
+ parts = [
143
+ record.asctime,
144
+ self._colorize(f"[{record.levelname}]", record.levelname),
145
+ record.name,
146
+ ":",
147
+ record.getMessage(),
148
+ ]
149
+
150
+ # Add trace context if enabled and available
151
+ if self.include_trace:
152
+ trace_parts = []
153
+ if hasattr(record, "operation") and record.operation:
154
+ trace_parts.append(f"op={record.operation}")
155
+ if hasattr(record, "trace_id") and record.trace_id:
156
+ short_trace = record.trace_id[:16]
157
+ trace_parts.append(f"trace={short_trace}...")
158
+ if hasattr(record, "span_id") and record.span_id:
159
+ trace_parts.append(f"span={record.span_id[:8]}")
160
+
161
+ if trace_parts:
162
+ parts.append(self._colorize(f"({', '.join(trace_parts)})", "DIM"))
163
+
164
+ # Add exception
165
+ result = " ".join(parts)
166
+ if record.exc_info:
167
+ result += "\n" + self.formatException(record.exc_info)
168
+
169
+ return result
170
+
171
+
172
+ class SimpleFormatter(logging.Formatter):
173
+ """Simple text formatter with optional components.
174
+
175
+ A more flexible version of the standard Formatter that allows
176
+ enabling/disabling specific fields.
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ fmt: Optional[str] = None,
182
+ datefmt: Optional[str] = None,
183
+ include_source: bool = False,
184
+ ):
185
+ """Initialize simple formatter.
186
+
187
+ :param fmt: Format string
188
+ :param datefmt: Date format string
189
+ :param include_source: Whether to include source file/line
190
+ """
191
+ if fmt is None:
192
+ parts = ["%(asctime)s", "[%(levelname)s]", "%(name)s:", "%(message)s"]
193
+ if include_source:
194
+ parts.append("(%(pathname)s:%(lineno)d)")
195
+ fmt = " ".join(parts)
196
+
197
+ super().__init__(fmt=fmt, datefmt=datefmt)
198
+
199
+
200
+ def configure_formatter(format_type: str = "text", **kwargs) -> logging.Formatter:
201
+ """Create a configured formatter.
202
+
203
+ Factory function to create formatters based on type.
204
+
205
+ :param format_type: Type of formatter ("text", "json", "colored")
206
+ :param kwargs: Additional arguments for specific formatter types
207
+ :returns: Configured Formatter instance
208
+
209
+ :raises ValueError: If format_type is not recognized
210
+
211
+ Example:
212
+ >>> formatter = configure_formatter("json", indent=2)
213
+ >>> handler = logging.StreamHandler()
214
+ >>> handler.setFormatter(formatter)
215
+ """
216
+ format_type = format_type.lower()
217
+
218
+ if format_type == "json":
219
+ return JSONFormatter(**kwargs)
220
+ elif format_type == "colored":
221
+ return ColoredTextFormatter(**kwargs)
222
+ elif format_type == "text":
223
+ return SimpleFormatter(**kwargs)
224
+ else:
225
+ raise ValueError(
226
+ f"Unknown format_type: {format_type}. "
227
+ "Choose from: 'text', 'json', 'colored'"
228
+ )
etcd3/log_sampling.py ADDED
@@ -0,0 +1,221 @@
1
+ """Log sampling utilities for etcd3 client.
2
+
3
+ This module provides sampling strategies to control log volume in high-throughput
4
+ scenarios, ensuring that important logs are always captured while reducing noise.
5
+ """
6
+
7
+ import logging
8
+ import random
9
+ import time
10
+ from contextvars import ContextVar
11
+ from enum import Enum, auto
12
+ from typing import Optional
13
+
14
+
15
+ class SamplingStrategy(Enum):
16
+ """Log sampling strategies.
17
+
18
+ - ALWAYS: Always log (no sampling)
19
+ - RATE: Sample at a fixed rate (e.g., 10%)
20
+ - DYNAMIC: Adjust sampling rate based on log volume
21
+ - LEVEL_BASED: Different sampling by log level
22
+ """
23
+
24
+ ALWAYS = auto()
25
+ RATE = auto()
26
+ DYNAMIC = auto()
27
+ LEVEL_BASED = auto()
28
+
29
+
30
+ class LogSampler:
31
+ """Log sampler with multiple strategies.
32
+
33
+ Controls log volume by sampling non-critical logs based on configured
34
+ strategy. Error logs are always emitted regardless of sampling.
35
+
36
+ Example:
37
+ >>> sampler = LogSampler(strategy=SamplingStrategy.RATE, rate=0.1)
38
+ >>> if sampler.should_log(logging.INFO, "kv.get"):
39
+ ... logger.info("Operation completed")
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ strategy: SamplingStrategy = SamplingStrategy.ALWAYS,
45
+ rate: float = 1.0,
46
+ burst: int = 100,
47
+ min_interval: float = 0.0,
48
+ level_rates: Optional[dict] = None,
49
+ ):
50
+ """Initialize log sampler.
51
+
52
+ :param strategy: Sampling strategy to use
53
+ :param rate: Sampling rate (0.0-1.0), 1.0 = always
54
+ :param burst: Maximum burst size for dynamic sampling
55
+ :param min_interval: Minimum interval between logs of same type (seconds)
56
+ :param level_rates: Per-level rates for LEVEL_BASED strategy
57
+ """
58
+ self.strategy = strategy
59
+ self.rate = max(0.0, min(1.0, rate))
60
+ self.burst = burst
61
+ self.min_interval = min_interval
62
+ self.level_rates = level_rates or {
63
+ logging.DEBUG: 0.1,
64
+ logging.INFO: 0.5,
65
+ logging.WARNING: 1.0,
66
+ }
67
+
68
+ # Context-local state for dynamic sampling
69
+ self._counters: ContextVar[dict] = ContextVar("sampler_counters", default={})
70
+ self._timestamps: ContextVar[dict] = ContextVar(
71
+ "sampler_timestamps", default={}
72
+ )
73
+
74
+ def should_log(
75
+ self,
76
+ level: int,
77
+ operation: Optional[str] = None,
78
+ ) -> bool:
79
+ """Determine if a log should be emitted.
80
+
81
+ :param level: Log level (from logging module)
82
+ :param operation: Operation identifier for per-operation sampling
83
+ :returns: True if log should be emitted
84
+ """
85
+ # Errors are always logged
86
+ if level >= logging.ERROR:
87
+ return True
88
+
89
+ # Apply strategy
90
+ if self.strategy == SamplingStrategy.ALWAYS:
91
+ return True
92
+
93
+ if self.strategy == SamplingStrategy.RATE:
94
+ return random.random() < self.rate
95
+
96
+ if self.strategy == SamplingStrategy.LEVEL_BASED:
97
+ level_rate = self.level_rates.get(level, 1.0)
98
+ return random.random() < level_rate
99
+
100
+ if self.strategy == SamplingStrategy.DYNAMIC:
101
+ return self._dynamic_sample(operation)
102
+
103
+ return True
104
+
105
+ def _dynamic_sample(self, operation: Optional[str]) -> bool:
106
+ """Dynamic sampling based on token bucket algorithm.
107
+
108
+ :param operation: Operation identifier
109
+ :returns: True if should log
110
+ """
111
+ key = operation or "default"
112
+ current_time = time.time()
113
+
114
+ # Check minimum interval
115
+ if self.min_interval > 0:
116
+ timestamps = self._timestamps.get()
117
+ last_time = timestamps.get(key, 0)
118
+ if current_time - last_time < self.min_interval:
119
+ return False
120
+
121
+ # Token bucket: allow burst, then sample
122
+ counters = self._counters.get()
123
+ count = counters.get(key, 0)
124
+
125
+ if count < self.burst:
126
+ # Within burst allowance
127
+ counters[key] = count + 1
128
+ timestamps = self._timestamps.get()
129
+ timestamps[key] = current_time
130
+ return True
131
+
132
+ # Beyond burst, sample at rate
133
+ if random.random() < self.rate:
134
+ timestamps = self._timestamps.get()
135
+ timestamps[key] = current_time
136
+ return True
137
+
138
+ return False
139
+
140
+ def reset(self):
141
+ """Reset sampler state.
142
+
143
+ Clears all counters and timestamps. Useful for testing or when
144
+ log volume characteristics change significantly.
145
+ """
146
+ self._counters.set({})
147
+ self._timestamps.set({})
148
+
149
+
150
+ # Global sampler instance
151
+ _default_sampler: Optional[LogSampler] = None
152
+
153
+
154
+ def get_sampler() -> LogSampler:
155
+ """Get the global log sampler instance.
156
+
157
+ Creates a default sampler if none exists.
158
+
159
+ :returns: Global LogSampler instance
160
+ """
161
+ global _default_sampler
162
+ if _default_sampler is None:
163
+ _default_sampler = LogSampler()
164
+ return _default_sampler
165
+
166
+
167
+ def set_sampler(sampler: LogSampler):
168
+ """Set the global log sampler instance.
169
+
170
+ :param sampler: LogSampler instance to use globally
171
+
172
+ Example:
173
+ >>> new_sampler = LogSampler(strategy=SamplingStrategy.RATE, rate=0.5)
174
+ >>> set_sampler(new_sampler)
175
+ """
176
+ global _default_sampler
177
+ _default_sampler = sampler
178
+
179
+
180
+ def should_log(
181
+ level: int,
182
+ operation: Optional[str] = None,
183
+ ) -> bool:
184
+ """Convenience function to check if should log using global sampler.
185
+
186
+ :param level: Log level
187
+ :param operation: Operation identifier
188
+ :returns: True if should log
189
+ """
190
+ return get_sampler().should_log(level, operation)
191
+
192
+
193
+ def configure_from_env() -> LogSampler:
194
+ """Configure sampler from environment variables.
195
+
196
+ Environment variables (unified with ETCD_OBS_ prefix):
197
+ - ETCD_OBS_LOG_SAMPLE_STRATEGY: "always", "rate", "dynamic", "level_based"
198
+ - ETCD_OBS_LOG_SAMPLE_RATE: Float between 0.0 and 1.0
199
+ - ETCD_OBS_LOG_SAMPLE_BURST: Integer burst size for dynamic sampling
200
+
201
+ :returns: Configured LogSampler
202
+ """
203
+ import os
204
+
205
+ ENV_PREFIX = "ETCD_OBS_"
206
+
207
+ strategy_str = os.environ.get(f"{ENV_PREFIX}LOG_SAMPLE_STRATEGY", "always").upper()
208
+ strategy_map = {
209
+ "ALWAYS": SamplingStrategy.ALWAYS,
210
+ "RATE": SamplingStrategy.RATE,
211
+ "DYNAMIC": SamplingStrategy.DYNAMIC,
212
+ "LEVEL_BASED": SamplingStrategy.LEVEL_BASED,
213
+ }
214
+ strategy = strategy_map.get(strategy_str, SamplingStrategy.ALWAYS)
215
+
216
+ rate = float(os.environ.get(f"{ENV_PREFIX}LOG_SAMPLE_RATE", "1.0"))
217
+ burst = int(os.environ.get(f"{ENV_PREFIX}LOG_SAMPLE_BURST", "100"))
218
+
219
+ sampler = LogSampler(strategy=strategy, rate=rate, burst=burst)
220
+ set_sampler(sampler)
221
+ return sampler
etcd3/members.py ADDED
@@ -0,0 +1,44 @@
1
+ class Member(object):
2
+ """
3
+ A member of the etcd cluster.
4
+
5
+ :ivar id: ID of the member
6
+ :ivar name: human-readable name of the member
7
+ :ivar peer_urls: list of URLs the member exposes to the cluster for
8
+ communication
9
+ :ivar client_urls: list of URLs the member exposes to clients for
10
+ communication
11
+ """
12
+
13
+ def __init__(self, id, name, peer_urls, client_urls, etcd_client=None):
14
+ self.id = id
15
+ self.name = name
16
+ self.peer_urls = peer_urls
17
+ self.client_urls = client_urls
18
+ self._etcd_client = etcd_client
19
+
20
+ def __str__(self):
21
+ return "Member {id}: peer urls: {peer_urls}, client urls: {client_urls}".format(
22
+ id=self.id, peer_urls=self.peer_urls, client_urls=self.client_urls
23
+ )
24
+
25
+ def remove(self):
26
+ """Remove this member from the cluster."""
27
+ self._etcd_client.remove_member(self.id)
28
+
29
+ def update(self, peer_urls):
30
+ """
31
+ Update the configuration of this member.
32
+
33
+ :param peer_urls: new list of peer urls the member will use to
34
+ communicate with the cluster
35
+ """
36
+ self._etcd_client.update_member(self.id, peer_urls)
37
+
38
+ @property
39
+ def active_alarms(self):
40
+ """Get active alarms of the member.
41
+
42
+ :returns: Alarms
43
+ """
44
+ return self._etcd_client.list_alarms(member_id=self.id)