taipanstack 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.
@@ -0,0 +1,328 @@
1
+ """
2
+ Structured logging with context.
3
+
4
+ Provides a configured logger with support for structured output,
5
+ context propagation, and proper formatting.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import sys
12
+ from contextlib import contextmanager
13
+ from datetime import UTC, datetime
14
+ from typing import Any, Literal
15
+
16
+ try:
17
+ import structlog
18
+
19
+ HAS_STRUCTLOG = True
20
+ except ImportError:
21
+ HAS_STRUCTLOG = False
22
+
23
+
24
+ # Default log format
25
+ DEFAULT_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
26
+ JSON_FORMAT = (
27
+ '{"timestamp": "%(asctime)s", "level": "%(levelname)s", '
28
+ '"logger": "%(name)s", "message": "%(message)s"}'
29
+ )
30
+
31
+
32
+ class StackLogger:
33
+ """Enhanced logger with context support.
34
+
35
+ Provides a wrapper around standard logging with additional features
36
+ like context propagation and structured output support.
37
+
38
+ Attributes:
39
+ name: Logger name.
40
+ level: Current log level.
41
+
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ name: str = "stack",
47
+ level: str = "INFO",
48
+ *,
49
+ use_structured: bool = False,
50
+ ) -> None:
51
+ """Initialize the logger.
52
+
53
+ Args:
54
+ name: Logger name.
55
+ level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
56
+ use_structured: Use structured logging if structlog is available.
57
+
58
+ """
59
+ self.name = name
60
+ self.level = level
61
+ self._context: dict[str, Any] = {}
62
+
63
+ if use_structured and HAS_STRUCTLOG:
64
+ self._logger = structlog.get_logger(name)
65
+ self._structured = True
66
+ else:
67
+ self._logger = logging.getLogger(name)
68
+ self._logger.setLevel(getattr(logging, level.upper()))
69
+ self._structured = False
70
+
71
+ def bind(self, **context: Any) -> StackLogger:
72
+ """Add context to logger.
73
+
74
+ Args:
75
+ **context: Key-value pairs to add to context.
76
+
77
+ Returns:
78
+ Self for chaining.
79
+
80
+ """
81
+ self._context.update(context)
82
+ if self._structured and HAS_STRUCTLOG:
83
+ self._logger = self._logger.bind(**context)
84
+ return self
85
+
86
+ def unbind(self, *keys: str) -> StackLogger:
87
+ """Remove context keys.
88
+
89
+ Args:
90
+ *keys: Keys to remove from context.
91
+
92
+ Returns:
93
+ Self for chaining.
94
+
95
+ """
96
+ for key in keys:
97
+ self._context.pop(key, None)
98
+ if self._structured and HAS_STRUCTLOG:
99
+ self._logger = self._logger.unbind(*keys)
100
+ return self
101
+
102
+ def _format_message(self, message: str, **kwargs: Any) -> str:
103
+ """Format message with context.
104
+
105
+ Args:
106
+ message: The log message.
107
+ **kwargs: Additional context for this message.
108
+
109
+ Returns:
110
+ Formatted message string.
111
+
112
+ """
113
+ if not kwargs and not self._context:
114
+ return message
115
+
116
+ context = {**self._context, **kwargs}
117
+ context_str = " ".join(f"{k}={v}" for k, v in context.items())
118
+ return f"{message} | {context_str}"
119
+
120
+ def debug(self, message: str, **kwargs: Any) -> None:
121
+ """Log a debug message.
122
+
123
+ Args:
124
+ message: The message to log.
125
+ **kwargs: Additional context.
126
+
127
+ """
128
+ if self._structured:
129
+ self._logger.debug(message, **kwargs)
130
+ else:
131
+ self._logger.debug(self._format_message(message, **kwargs))
132
+
133
+ def info(self, message: str, **kwargs: Any) -> None:
134
+ """Log an info message.
135
+
136
+ Args:
137
+ message: The message to log.
138
+ **kwargs: Additional context.
139
+
140
+ """
141
+ if self._structured:
142
+ self._logger.info(message, **kwargs)
143
+ else:
144
+ self._logger.info(self._format_message(message, **kwargs))
145
+
146
+ def warning(self, message: str, **kwargs: Any) -> None:
147
+ """Log a warning message.
148
+
149
+ Args:
150
+ message: The message to log.
151
+ **kwargs: Additional context.
152
+
153
+ """
154
+ if self._structured:
155
+ self._logger.warning(message, **kwargs)
156
+ else:
157
+ self._logger.warning(self._format_message(message, **kwargs))
158
+
159
+ def error(self, message: str, **kwargs: Any) -> None:
160
+ """Log an error message.
161
+
162
+ Args:
163
+ message: The message to log.
164
+ **kwargs: Additional context.
165
+
166
+ """
167
+ if self._structured:
168
+ self._logger.error(message, **kwargs)
169
+ else:
170
+ self._logger.error(self._format_message(message, **kwargs))
171
+
172
+ def critical(self, message: str, **kwargs: Any) -> None:
173
+ """Log a critical message.
174
+
175
+ Args:
176
+ message: The message to log.
177
+ **kwargs: Additional context.
178
+
179
+ """
180
+ if self._structured:
181
+ self._logger.critical(message, **kwargs)
182
+ else:
183
+ self._logger.critical(self._format_message(message, **kwargs))
184
+
185
+ def exception(self, message: str, **kwargs: Any) -> None:
186
+ """Log an exception with traceback.
187
+
188
+ Args:
189
+ message: The message to log.
190
+ **kwargs: Additional context.
191
+
192
+ """
193
+ if self._structured:
194
+ self._logger.exception(message, **kwargs)
195
+ else:
196
+ self._logger.exception(self._format_message(message, **kwargs))
197
+
198
+
199
+ def setup_logging(
200
+ level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
201
+ *,
202
+ format_type: Literal["simple", "detailed", "json"] = "detailed",
203
+ log_file: str | None = None,
204
+ use_structured: bool = False,
205
+ ) -> None:
206
+ """Configure the root logger.
207
+
208
+ Args:
209
+ level: Log level to set.
210
+ format_type: Output format type.
211
+ log_file: Optional file to log to.
212
+ use_structured: Use structlog if available.
213
+
214
+ """
215
+ # Configure structlog if available and requested
216
+ if use_structured and HAS_STRUCTLOG:
217
+ structlog.configure(
218
+ processors=[
219
+ structlog.stdlib.filter_by_level,
220
+ structlog.stdlib.add_logger_name,
221
+ structlog.stdlib.add_log_level,
222
+ structlog.processors.TimeStamper(fmt="iso"),
223
+ structlog.processors.StackInfoRenderer(),
224
+ structlog.processors.format_exc_info,
225
+ structlog.processors.UnicodeDecoder(),
226
+ structlog.processors.JSONRenderer(),
227
+ ],
228
+ wrapper_class=structlog.stdlib.BoundLogger,
229
+ context_class=dict,
230
+ logger_factory=structlog.stdlib.LoggerFactory(),
231
+ )
232
+ return
233
+
234
+ # Standard logging configuration
235
+ if format_type == "simple":
236
+ log_format = "%(levelname)s: %(message)s"
237
+ elif format_type == "json":
238
+ log_format = JSON_FORMAT
239
+ else:
240
+ log_format = DEFAULT_FORMAT
241
+
242
+ handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)]
243
+
244
+ if log_file:
245
+ handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
246
+
247
+ logging.basicConfig(
248
+ level=getattr(logging, level.upper()),
249
+ format=log_format,
250
+ handlers=handlers,
251
+ force=True,
252
+ )
253
+
254
+
255
+ def get_logger(
256
+ name: str = "stack",
257
+ *,
258
+ level: str = "INFO",
259
+ use_structured: bool = False,
260
+ ) -> StackLogger:
261
+ """Get a configured logger instance.
262
+
263
+ Args:
264
+ name: Logger name.
265
+ level: Log level.
266
+ use_structured: Use structlog if available.
267
+
268
+ Returns:
269
+ Configured StackLogger instance.
270
+
271
+ Example:
272
+ >>> logger = get_logger("my_module")
273
+ >>> logger.bind(request_id="123").info("Processing request")
274
+
275
+ """
276
+ return StackLogger(name, level, use_structured=use_structured)
277
+
278
+
279
+ def log_operation(
280
+ operation: str,
281
+ *,
282
+ logger: StackLogger | None = None,
283
+ level: str = "INFO",
284
+ ) -> Any:
285
+ """Context manager for logging operations.
286
+
287
+ Args:
288
+ operation: Name of the operation.
289
+ logger: Logger to use (creates one if not provided).
290
+ level: Log level for messages.
291
+
292
+ Yields:
293
+ The logger instance.
294
+
295
+ Example:
296
+ >>> with log_operation("setup") as logger:
297
+ ... logger.info("Setting up environment")
298
+
299
+ """
300
+
301
+ @contextmanager
302
+ def _log_context() -> Any:
303
+ nonlocal logger
304
+ if logger is None:
305
+ logger = get_logger()
306
+
307
+ start_time = datetime.now(UTC)
308
+ logger.bind(operation=operation)
309
+
310
+ log_method = getattr(logger, level.lower())
311
+ log_method(f"Starting: {operation}")
312
+
313
+ try:
314
+ yield logger
315
+ duration = (datetime.now(UTC) - start_time).total_seconds()
316
+ log_method(f"Completed: {operation}", duration_seconds=duration)
317
+ except Exception as e:
318
+ duration = (datetime.now(UTC) - start_time).total_seconds()
319
+ logger.exception(
320
+ f"Failed: {operation}",
321
+ duration_seconds=duration,
322
+ error=str(e),
323
+ )
324
+ raise
325
+ finally:
326
+ logger.unbind("operation")
327
+
328
+ return _log_context()
@@ -0,0 +1,272 @@
1
+ """
2
+ Metrics collection and monitoring utilities.
3
+
4
+ Provides lightweight metrics collection for monitoring
5
+ application performance and health. Compatible with any
6
+ Python framework.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import logging
13
+ import threading
14
+ import time
15
+ from collections import defaultdict
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, ParamSpec, TypeVar
19
+
20
+ P = ParamSpec("P")
21
+ R = TypeVar("R")
22
+
23
+ logger = logging.getLogger("taipanstack.utils.metrics")
24
+
25
+
26
+ @dataclass
27
+ class TimingStats:
28
+ """Statistics for timing measurements."""
29
+
30
+ count: int = 0
31
+ total_time: float = 0.0
32
+ min_time: float = float("inf")
33
+ max_time: float = 0.0
34
+
35
+ @property
36
+ def avg_time(self) -> float:
37
+ """Calculate average time."""
38
+ return self.total_time / self.count if self.count > 0 else 0.0
39
+
40
+ def record(self, duration: float) -> None:
41
+ """Record a timing measurement."""
42
+ self.count += 1
43
+ self.total_time += duration
44
+ self.min_time = min(self.min_time, duration)
45
+ self.max_time = max(self.max_time, duration)
46
+
47
+
48
+ @dataclass
49
+ class Counter:
50
+ """Simple counter metric."""
51
+
52
+ value: int = 0
53
+ lock: threading.Lock = field(default_factory=threading.Lock)
54
+
55
+ def increment(self, amount: int = 1) -> int:
56
+ """Increment counter and return new value."""
57
+ with self.lock:
58
+ self.value += amount
59
+ return self.value
60
+
61
+ def decrement(self, amount: int = 1) -> int:
62
+ """Decrement counter and return new value."""
63
+ with self.lock:
64
+ self.value -= amount
65
+ return self.value
66
+
67
+ def reset(self) -> None:
68
+ """Reset counter to zero."""
69
+ with self.lock:
70
+ self.value = 0
71
+
72
+
73
+ class MetricsCollector:
74
+ """Centralized metrics collection.
75
+
76
+ Thread-safe collector for various metric types including
77
+ counters, timers, and gauges.
78
+
79
+ Example:
80
+ >>> metrics = MetricsCollector()
81
+ >>> metrics.increment("requests_total")
82
+ >>> with metrics.timer("request_duration"):
83
+ ... process_request()
84
+
85
+ """
86
+
87
+ _instance: MetricsCollector | None = None
88
+ _lock = threading.Lock()
89
+ _initialized: bool
90
+
91
+ def __new__(cls) -> MetricsCollector:
92
+ """Singleton pattern for global metrics access."""
93
+ if cls._instance is None:
94
+ with cls._lock:
95
+ if cls._instance is None:
96
+ cls._instance = super().__new__(cls)
97
+ cls._instance._initialized = False
98
+ return cls._instance
99
+
100
+ def __init__(self) -> None:
101
+ """Initialize metrics collector."""
102
+ if self._initialized:
103
+ return
104
+
105
+ self._counters: dict[str, Counter] = defaultdict(Counter)
106
+ self._timers: dict[str, TimingStats] = defaultdict(TimingStats)
107
+ self._gauges: dict[str, float] = {}
108
+ self._data_lock = threading.Lock()
109
+ self._initialized = True
110
+
111
+ def increment(self, name: str, amount: int = 1) -> int:
112
+ """Increment a counter metric."""
113
+ return self._counters[name].increment(amount)
114
+
115
+ def decrement(self, name: str, amount: int = 1) -> int:
116
+ """Decrement a counter metric."""
117
+ return self._counters[name].decrement(amount)
118
+
119
+ def gauge(self, name: str, value: float) -> None:
120
+ """Set a gauge metric value."""
121
+ with self._data_lock:
122
+ self._gauges[name] = value
123
+
124
+ def get_gauge(self, name: str) -> float | None:
125
+ """Get a gauge metric value."""
126
+ with self._data_lock:
127
+ return self._gauges.get(name)
128
+
129
+ def record_time(self, name: str, duration: float) -> None:
130
+ """Record a timing measurement."""
131
+ self._timers[name].record(duration)
132
+
133
+ def timer(self, name: str) -> Timer:
134
+ """Create a context manager timer."""
135
+ return Timer(name, self)
136
+
137
+ def get_counter(self, name: str) -> int:
138
+ """Get current counter value."""
139
+ return self._counters[name].value
140
+
141
+ def get_timer_stats(self, name: str) -> TimingStats | None:
142
+ """Get timing statistics for a named timer."""
143
+ return self._timers.get(name)
144
+
145
+ def get_all_metrics(self) -> dict[str, Any]:
146
+ """Get all metrics as a dictionary."""
147
+ with self._data_lock:
148
+ return {
149
+ "counters": {k: v.value for k, v in self._counters.items()},
150
+ "timers": {
151
+ k: {
152
+ "count": v.count,
153
+ "avg": v.avg_time,
154
+ "min": v.min_time if v.count > 0 else 0,
155
+ "max": v.max_time,
156
+ "total": v.total_time,
157
+ }
158
+ for k, v in self._timers.items()
159
+ },
160
+ "gauges": dict(self._gauges),
161
+ }
162
+
163
+ def reset(self) -> None:
164
+ """Reset all metrics."""
165
+ with self._data_lock:
166
+ self._counters.clear()
167
+ self._timers.clear()
168
+ self._gauges.clear()
169
+
170
+
171
+ class Timer:
172
+ """Context manager for timing code blocks."""
173
+
174
+ def __init__(self, name: str, collector: MetricsCollector) -> None:
175
+ """Initialize timer.
176
+
177
+ Args:
178
+ name: Name of the timer metric.
179
+ collector: MetricsCollector to record to.
180
+
181
+ """
182
+ self.name = name
183
+ self.collector = collector
184
+ self.start_time: float = 0.0
185
+
186
+ def __enter__(self) -> Timer:
187
+ """Start the timer."""
188
+ self.start_time = time.perf_counter()
189
+ return self
190
+
191
+ def __exit__(self, *args: Any) -> None:
192
+ """Stop timer and record duration."""
193
+ duration = time.perf_counter() - self.start_time
194
+ self.collector.record_time(self.name, duration)
195
+
196
+
197
+ def timed(
198
+ name: str | None = None,
199
+ *,
200
+ collector: MetricsCollector | None = None,
201
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
202
+ """Decorator to time function execution.
203
+
204
+ Args:
205
+ name: Optional metric name (defaults to function name).
206
+ collector: Optional MetricsCollector instance.
207
+
208
+ Returns:
209
+ Decorated function that records execution time.
210
+
211
+ Example:
212
+ >>> @timed("api_call_duration")
213
+ ... def call_api(endpoint: str) -> dict:
214
+ ... return requests.get(endpoint).json()
215
+
216
+ """
217
+
218
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
219
+ metric_name = name or func.__name__
220
+ metrics = collector or MetricsCollector()
221
+
222
+ @functools.wraps(func)
223
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
224
+ start = time.perf_counter()
225
+ try:
226
+ return func(*args, **kwargs)
227
+ finally:
228
+ duration = time.perf_counter() - start
229
+ metrics.record_time(metric_name, duration)
230
+
231
+ return wrapper
232
+
233
+ return decorator
234
+
235
+
236
+ def counted(
237
+ name: str | None = None,
238
+ *,
239
+ collector: MetricsCollector | None = None,
240
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
241
+ """Decorator to count function calls.
242
+
243
+ Args:
244
+ name: Optional metric name (defaults to function name).
245
+ collector: Optional MetricsCollector instance.
246
+
247
+ Returns:
248
+ Decorated function that counts calls.
249
+
250
+ Example:
251
+ >>> @counted("login_attempts")
252
+ ... def login(username: str, password: str) -> bool:
253
+ ... return authenticate(username, password)
254
+
255
+ """
256
+
257
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
258
+ metric_name = name or f"{func.__name__}_calls"
259
+ metrics = collector or MetricsCollector()
260
+
261
+ @functools.wraps(func)
262
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
263
+ metrics.increment(metric_name)
264
+ return func(*args, **kwargs)
265
+
266
+ return wrapper
267
+
268
+ return decorator
269
+
270
+
271
+ # Global metrics instance
272
+ metrics = MetricsCollector()