kstlib 0.0.1a0__py3-none-any.whl → 1.0.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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1090 @@
1
+ """Unified metrics decorator for timing and profiling.
2
+
3
+ Provides a single config-driven decorator for all profiling needs:
4
+
5
+ - ``@metrics``: Use defaults from config (time + memory)
6
+ - ``@metrics(memory=False)``: Time only
7
+ - ``@metrics(step=True)``: Numbered step tracking
8
+ - ``@metrics("Custom title")``: With custom label
9
+
10
+ Examples:
11
+ Basic usage with config defaults:
12
+
13
+ >>> @metrics
14
+ ... def process_data():
15
+ ... return sum(range(1000000))
16
+ >>> process_data() # doctest: +SKIP
17
+ [process_data] ⏱ 0.023s | 🧠 Peak: 128 KB
18
+
19
+ Step tracking:
20
+
21
+ >>> @metrics(step=True)
22
+ ... def load_data():
23
+ ... pass
24
+ >>> load_data() # doctest: +SKIP
25
+ [STEP 1] load_data ⏱ 0.001s | 🧠 Peak: 64 KB
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import functools
31
+ import inspect
32
+ import sys
33
+ import threading
34
+ import time as time_module
35
+ import tracemalloc
36
+ from contextlib import contextmanager
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+ from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, overload
40
+
41
+ from rich.console import Console
42
+ from rich.text import Text
43
+
44
+ if TYPE_CHECKING:
45
+ from collections.abc import Callable, Generator
46
+
47
+ P = ParamSpec("P")
48
+ R = TypeVar("R")
49
+
50
+ # Default theme (Rich style names)
51
+ _DEFAULT_THEME = {
52
+ "label": "bold green",
53
+ "title": "bold white",
54
+ "text": "white",
55
+ "muted": "dim",
56
+ "table_header": "bold cyan",
57
+ "time_ok": "cyan",
58
+ "time_warn": "yellow",
59
+ "time_crit": "bold red",
60
+ "memory_ok": "magenta",
61
+ "memory_warn": "yellow",
62
+ "memory_crit": "bold red",
63
+ "step_number": "dim",
64
+ "separator": "dim white",
65
+ }
66
+
67
+ # Default thresholds
68
+ _DEFAULT_THRESHOLDS = {
69
+ "time_warn": 5,
70
+ "time_crit": 30,
71
+ "memory_warn": 100_000_000,
72
+ "memory_crit": 500_000_000,
73
+ }
74
+
75
+ # Default icons (empty string to disable)
76
+ _DEFAULT_ICONS = {
77
+ "time": "⏱",
78
+ "memory": "🧠",
79
+ "peak": "Peak:",
80
+ }
81
+
82
+
83
+ def _get_config() -> dict[str, Any]:
84
+ """Get metrics config with defaults."""
85
+ defaults = {
86
+ "colors": True,
87
+ "time": True,
88
+ "memory": True,
89
+ "step": False,
90
+ "step_format": "[STEP {n}] {title}",
91
+ "lap_format": "[LAP {n}] {name}",
92
+ "title_format": "{function}",
93
+ "thresholds": dict(_DEFAULT_THRESHOLDS),
94
+ "theme": dict(_DEFAULT_THEME),
95
+ "icons": dict(_DEFAULT_ICONS),
96
+ }
97
+ try:
98
+ from kstlib.config import get_config
99
+
100
+ config = get_config()
101
+ metrics_cfg = config.get("metrics", {}) # type: ignore[no-untyped-call]
102
+ cfg_defaults = metrics_cfg.get("defaults", {})
103
+ cfg_thresholds = metrics_cfg.get("thresholds", {})
104
+ cfg_theme = metrics_cfg.get("theme", {})
105
+ cfg_icons = metrics_cfg.get("icons", {})
106
+
107
+ defaults.update(
108
+ {
109
+ "colors": metrics_cfg.get("colors", defaults["colors"]),
110
+ "time": cfg_defaults.get("time", defaults["time"]),
111
+ "memory": cfg_defaults.get("memory", defaults["memory"]),
112
+ "step": cfg_defaults.get("step", defaults["step"]),
113
+ "step_format": metrics_cfg.get("step_format", defaults["step_format"]),
114
+ "lap_format": metrics_cfg.get("lap_format", defaults["lap_format"]),
115
+ "title_format": metrics_cfg.get("title_format", defaults["title_format"]),
116
+ "thresholds": {**_DEFAULT_THRESHOLDS, **cfg_thresholds},
117
+ "theme": {**_DEFAULT_THEME, **cfg_theme},
118
+ "icons": {**_DEFAULT_ICONS, **cfg_icons},
119
+ }
120
+ )
121
+ except Exception:
122
+ pass # Config loading is optional, use defaults
123
+ return defaults
124
+
125
+
126
+ def _get_console() -> Console:
127
+ """Get a Rich console for stderr output."""
128
+ cfg = _get_config()
129
+ return Console(
130
+ file=sys.stderr,
131
+ force_terminal=cfg["colors"],
132
+ no_color=not cfg["colors"],
133
+ )
134
+
135
+
136
+ def _format_bytes(num_bytes: int) -> str:
137
+ """Format bytes to human-readable string.
138
+
139
+ Examples:
140
+ >>> _format_bytes(1024)
141
+ '1.0 KB'
142
+ >>> _format_bytes(1536 * 1024 * 1024)
143
+ '1.5 GB'
144
+ """
145
+ value = float(num_bytes)
146
+ for unit in ("B", "KB", "MB", "GB", "TB"):
147
+ if abs(value) < 1024:
148
+ return f"{value:.1f} {unit}"
149
+ value /= 1024
150
+ return f"{value:.1f} PB"
151
+
152
+
153
+ def _format_time(seconds: float) -> str:
154
+ """Format seconds to human-readable string.
155
+
156
+ Examples:
157
+ >>> _format_time(0.5)
158
+ '0.500s'
159
+ >>> _format_time(90)
160
+ '1m 30s'
161
+ """
162
+ if seconds < 60:
163
+ return f"{seconds:.3f}s"
164
+ if seconds < 3600:
165
+ minutes = int(seconds // 60)
166
+ secs = seconds % 60
167
+ return f"{minutes}m {secs:.0f}s"
168
+ hours = int(seconds // 3600)
169
+ minutes = int((seconds % 3600) // 60)
170
+ return f"{hours}h {minutes}m"
171
+
172
+
173
+ def _get_time_style(seconds: float, cfg: dict[str, Any] | None = None) -> str:
174
+ """Get Rich style based on duration thresholds."""
175
+ if cfg is None:
176
+ cfg = _get_config()
177
+ thresholds: dict[str, int | float] = cfg["thresholds"]
178
+ theme: dict[str, str] = cfg["theme"]
179
+ if seconds >= thresholds["time_crit"]:
180
+ return str(theme["time_crit"])
181
+ if seconds >= thresholds["time_warn"]:
182
+ return str(theme["time_warn"])
183
+ return str(theme["time_ok"])
184
+
185
+
186
+ def _get_memory_style(num_bytes: int, cfg: dict[str, Any] | None = None) -> str:
187
+ """Get Rich style based on memory thresholds."""
188
+ if cfg is None:
189
+ cfg = _get_config()
190
+ thresholds: dict[str, int | float] = cfg["thresholds"]
191
+ theme: dict[str, str] = cfg["theme"]
192
+ if num_bytes >= thresholds["memory_crit"]:
193
+ return str(theme["memory_crit"])
194
+ if num_bytes >= thresholds["memory_warn"]:
195
+ return str(theme["memory_warn"])
196
+ return str(theme["memory_ok"])
197
+
198
+
199
+ # =============================================================================
200
+ # Global step registry
201
+ # =============================================================================
202
+
203
+
204
+ @dataclass
205
+ class MetricsRecord:
206
+ """Record of a metrics measurement.
207
+
208
+ Attributes:
209
+ number: Step number (if step=True).
210
+ title: Display title.
211
+ elapsed_seconds: Execution time.
212
+ peak_memory_bytes: Peak memory usage.
213
+ function: Function name.
214
+ module: Module name.
215
+ file: Source file.
216
+ line: Source line.
217
+ """
218
+
219
+ number: int | None
220
+ title: str
221
+ elapsed_seconds: float = 0.0
222
+ peak_memory_bytes: int | None = None
223
+ function: str = ""
224
+ module: str = ""
225
+ file: str = ""
226
+ line: int = 0
227
+
228
+ @property
229
+ def elapsed_formatted(self) -> str:
230
+ """Return formatted elapsed time."""
231
+ return _format_time(self.elapsed_seconds)
232
+
233
+ @property
234
+ def peak_memory_formatted(self) -> str | None:
235
+ """Return formatted peak memory."""
236
+ if self.peak_memory_bytes is None:
237
+ return None
238
+ return _format_bytes(self.peak_memory_bytes)
239
+
240
+
241
+ _records: list[MetricsRecord] = []
242
+ _step_counter = 0
243
+ _records_lock = threading.Lock()
244
+ _program_start: float | None = None
245
+
246
+
247
+ def _next_step_number() -> int:
248
+ """Get and increment the global step counter."""
249
+ global _step_counter, _program_start
250
+ with _records_lock:
251
+ if _program_start is None:
252
+ _program_start = time_module.perf_counter()
253
+ _step_counter += 1
254
+ return _step_counter
255
+
256
+
257
+ def _register_record(record: MetricsRecord) -> None:
258
+ """Register a completed record."""
259
+ with _records_lock:
260
+ _records.append(record)
261
+
262
+
263
+ def get_metrics() -> list[MetricsRecord]:
264
+ """Get all recorded metrics.
265
+
266
+ Returns:
267
+ List of MetricsRecord objects.
268
+
269
+ Examples:
270
+ >>> records = get_metrics()
271
+ >>> isinstance(records, list)
272
+ True
273
+ """
274
+ with _records_lock:
275
+ return list(_records)
276
+
277
+
278
+ def clear_metrics() -> None:
279
+ """Clear all recorded metrics and reset step counter.
280
+
281
+ Examples:
282
+ >>> clear_metrics()
283
+ """
284
+ global _step_counter, _program_start
285
+ with _records_lock:
286
+ _records.clear()
287
+ _step_counter = 0
288
+ _program_start = None
289
+
290
+
291
+ def _print_metrics(record: MetricsRecord, *, show_time: bool, show_memory: bool) -> None:
292
+ """Print a metrics result with Rich colors."""
293
+ cfg = _get_config()
294
+ theme: dict[str, str] = cfg["theme"]
295
+ icons: dict[str, str] = cfg["icons"]
296
+ console = _get_console()
297
+
298
+ # Build Rich Text with styled parts
299
+ output = Text()
300
+
301
+ # Build label - supports Rich markup in title/step_format
302
+ if record.number is not None:
303
+ step_format = cfg["step_format"]
304
+ label_str = step_format.format(
305
+ n=record.number,
306
+ title=record.title,
307
+ function=record.function,
308
+ module=record.module,
309
+ file=record.file,
310
+ line=record.line,
311
+ )
312
+ # Parse Rich markup in step_format (e.g., "[STEP {n}] [bold]{title}[/bold]")
313
+ label_text = Text.from_markup(label_str, style=theme["label"])
314
+ output.append(label_text)
315
+ else:
316
+ # For non-step: wrap title in brackets, parse markup inside title
317
+ # e.g., title = "my_func [dim green](file.py:42)[/dim green]"
318
+ # Result: "[my_func (file.py:42)]" with styled parts
319
+ output.append("[", style=theme["label"])
320
+ title_text = Text.from_markup(record.title, style=theme["label"])
321
+ output.append(title_text)
322
+ output.append("]", style=theme["label"])
323
+
324
+ if show_time:
325
+ time_style = _get_time_style(record.elapsed_seconds, cfg)
326
+ output.append(" | " if show_memory and record.peak_memory_bytes else " ", style=theme["separator"])
327
+ time_icon = f"{icons['time']} " if icons["time"] else ""
328
+ output.append(f"{time_icon}{record.elapsed_formatted}", style=time_style)
329
+
330
+ if show_memory and record.peak_memory_bytes is not None:
331
+ mem_style = _get_memory_style(record.peak_memory_bytes, cfg)
332
+ output.append(" | ", style=theme["separator"])
333
+ mem_icon = f"{icons['memory']} " if icons["memory"] else ""
334
+ peak_text = f"{icons['peak']} " if icons["peak"] else ""
335
+ output.append(f"{mem_icon}{peak_text}{record.peak_memory_formatted}", style=mem_style)
336
+
337
+ console.print(output)
338
+
339
+
340
+ # =============================================================================
341
+ # Main @metrics decorator
342
+ # =============================================================================
343
+
344
+
345
+ @overload
346
+ def metrics(func: Callable[P, R], /) -> Callable[P, R]: ...
347
+
348
+
349
+ @overload
350
+ def metrics(
351
+ title: str,
352
+ /,
353
+ *,
354
+ time: bool | None = None,
355
+ memory: bool | None = None,
356
+ step: bool | None = None,
357
+ print_result: bool = True,
358
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
359
+
360
+
361
+ @overload
362
+ def metrics(
363
+ func: None = None,
364
+ /,
365
+ *,
366
+ title: str | None = None,
367
+ time: bool | None = None,
368
+ memory: bool | None = None,
369
+ step: bool | None = None,
370
+ print_result: bool = True,
371
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
372
+
373
+
374
+ def metrics(
375
+ func_or_title: Callable[P, R] | str | None = None,
376
+ /,
377
+ *,
378
+ title: str | None = None,
379
+ time: bool | None = None,
380
+ memory: bool | None = None,
381
+ step: bool | None = None,
382
+ print_result: bool = True,
383
+ ) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
384
+ """Unified decorator for timing, memory tracking, and step counting.
385
+
386
+ Config-driven with sensible defaults. All options can be overridden.
387
+
388
+ Args:
389
+ func_or_title: Function to decorate or custom title string.
390
+ title: Custom title (alternative to positional).
391
+ time: Track execution time (default from config, typically True).
392
+ memory: Track peak memory (default from config, typically True).
393
+ step: Enable step numbering (default from config, typically False).
394
+ print_result: Whether to print result to stderr.
395
+
396
+ Returns:
397
+ Decorated function.
398
+
399
+ Examples:
400
+ Default behavior (time + memory from config):
401
+
402
+ >>> @metrics
403
+ ... def process():
404
+ ... return sum(range(1000))
405
+ >>> process() # doctest: +SKIP
406
+ [process] ⏱ 0.001s | 🧠 Peak: 64 KB
407
+
408
+ Time only:
409
+
410
+ >>> @metrics(memory=False)
411
+ ... def quick():
412
+ ... pass
413
+
414
+ With step numbering:
415
+
416
+ >>> @metrics(step=True)
417
+ ... def step1():
418
+ ... pass
419
+ >>> step1() # doctest: +SKIP
420
+ [STEP 1] step1 ⏱ 0.001s | 🧠 Peak: 32 KB
421
+
422
+ Custom title:
423
+
424
+ >>> @metrics("Loading configuration")
425
+ ... def load_config():
426
+ ... pass
427
+ """
428
+ # Handle @metrics (no parens, func passed directly)
429
+ if callable(func_or_title):
430
+ return _create_metrics_wrapper(func_or_title, None, time, memory, step, print_result)
431
+
432
+ # Handle @metrics("title") or @metrics(title="title", ...)
433
+ actual_title = func_or_title if isinstance(func_or_title, str) else title
434
+
435
+ def decorator(fn: Callable[P, R]) -> Callable[P, R]:
436
+ return _create_metrics_wrapper(fn, actual_title, time, memory, step, print_result)
437
+
438
+ return decorator
439
+
440
+
441
+ def _create_metrics_wrapper(
442
+ fn: Callable[P, R],
443
+ custom_title: str | None,
444
+ track_time: bool | None,
445
+ track_memory: bool | None,
446
+ use_step: bool | None,
447
+ print_result: bool,
448
+ ) -> Callable[P, R]:
449
+ """Create the actual metrics wrapper function."""
450
+ # Get source info at decoration time
451
+ try:
452
+ source_file = inspect.getfile(fn)
453
+ _, start_line = inspect.getsourcelines(fn)
454
+ source_file = Path(source_file).name
455
+ except (TypeError, OSError):
456
+ source_file = "<unknown>"
457
+ start_line = 0
458
+
459
+ module_name = fn.__module__ or "<unknown>"
460
+ func_name = fn.__name__
461
+
462
+ # Build display title from config title_format format or custom title
463
+ if custom_title:
464
+ display_title = custom_title
465
+ else:
466
+ cfg = _get_config()
467
+ title_format_fmt = cfg.get("title_format", "{function}")
468
+ display_title = title_format_fmt.format(
469
+ function=func_name,
470
+ module=module_name,
471
+ file=source_file,
472
+ line=start_line,
473
+ )
474
+
475
+ @functools.wraps(fn)
476
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
477
+ # Get config defaults, allow overrides
478
+ cfg = _get_config()
479
+ do_time = track_time if track_time is not None else cfg["time"]
480
+ do_memory = track_memory if track_memory is not None else cfg["memory"]
481
+ do_step = use_step if use_step is not None else cfg["step"]
482
+
483
+ step_num = _next_step_number() if do_step else None
484
+
485
+ record = MetricsRecord(
486
+ number=step_num,
487
+ title=display_title,
488
+ function=func_name,
489
+ module=module_name,
490
+ file=source_file,
491
+ line=start_line,
492
+ )
493
+
494
+ # Setup memory tracking if needed
495
+ was_tracing = False
496
+ if do_memory:
497
+ was_tracing = tracemalloc.is_tracing()
498
+ if not was_tracing:
499
+ tracemalloc.start()
500
+ tracemalloc.reset_peak()
501
+
502
+ start = time_module.perf_counter()
503
+ try:
504
+ return fn(*args, **kwargs)
505
+ finally:
506
+ record.elapsed_seconds = time_module.perf_counter() - start
507
+
508
+ if do_memory:
509
+ _, peak = tracemalloc.get_traced_memory()
510
+ record.peak_memory_bytes = peak
511
+ if not was_tracing:
512
+ tracemalloc.stop()
513
+
514
+ if do_step:
515
+ _register_record(record)
516
+
517
+ if print_result:
518
+ _print_metrics(record, show_time=do_time, show_memory=do_memory)
519
+
520
+ return wrapper
521
+
522
+
523
+ # =============================================================================
524
+ # Context manager version
525
+ # =============================================================================
526
+
527
+
528
+ @contextmanager
529
+ def metrics_context(
530
+ title: str,
531
+ *,
532
+ time: bool | None = None,
533
+ memory: bool | None = None,
534
+ step: bool | None = None,
535
+ print_result: bool = True,
536
+ ) -> Generator[MetricsRecord, None, None]:
537
+ """Context manager for metrics tracking.
538
+
539
+ Args:
540
+ title: Title for this measurement.
541
+ time: Track execution time.
542
+ memory: Track peak memory.
543
+ step: Enable step numbering.
544
+ print_result: Whether to print result.
545
+
546
+ Yields:
547
+ MetricsRecord being tracked.
548
+
549
+ Examples:
550
+ >>> with metrics_context("Loading data") as m: # doctest: +SKIP
551
+ ... data = load_file()
552
+ [Loading data] ⏱ 1.23s | 🧠 Peak: 256 MB
553
+ """
554
+ cfg = _get_config()
555
+ do_time = time if time is not None else cfg["time"]
556
+ do_memory = memory if memory is not None else cfg["memory"]
557
+ do_step = step if step is not None else cfg["step"]
558
+
559
+ # Get caller info
560
+ frame = inspect.currentframe()
561
+ if frame and frame.f_back and frame.f_back.f_back:
562
+ caller = frame.f_back.f_back
563
+ source_file = Path(caller.f_code.co_filename).name
564
+ line_num = caller.f_lineno
565
+ func_name = caller.f_code.co_name
566
+ module_name = caller.f_globals.get("__name__", "<unknown>")
567
+ else:
568
+ source_file, line_num, func_name, module_name = "<unknown>", 0, "<unknown>", "<unknown>"
569
+
570
+ step_num = _next_step_number() if do_step else None
571
+
572
+ record = MetricsRecord(
573
+ number=step_num,
574
+ title=title,
575
+ function=func_name,
576
+ module=module_name,
577
+ file=source_file,
578
+ line=line_num,
579
+ )
580
+
581
+ # Setup memory tracking
582
+ was_tracing = False
583
+ if do_memory:
584
+ was_tracing = tracemalloc.is_tracing()
585
+ if not was_tracing:
586
+ tracemalloc.start()
587
+ tracemalloc.reset_peak()
588
+
589
+ start = time_module.perf_counter()
590
+ try:
591
+ yield record
592
+ finally:
593
+ record.elapsed_seconds = time_module.perf_counter() - start
594
+
595
+ if do_memory:
596
+ _, peak = tracemalloc.get_traced_memory()
597
+ record.peak_memory_bytes = peak
598
+ if not was_tracing:
599
+ tracemalloc.stop()
600
+
601
+ if do_step:
602
+ _register_record(record)
603
+
604
+ if print_result:
605
+ _print_metrics(record, show_time=do_time, show_memory=do_memory)
606
+
607
+
608
+ # =============================================================================
609
+ # Summary
610
+ # =============================================================================
611
+
612
+
613
+ def metrics_summary(*, show_percentages: bool = True, style: str = "table") -> None:
614
+ """Print summary of all recorded metrics (step=True records).
615
+
616
+ Uses kstlib.ui.tables for pretty output.
617
+
618
+ Args:
619
+ show_percentages: Whether to show percentage of total time.
620
+ style: Output style - "table" (Rich table) or "simple" (plain text).
621
+
622
+ Examples:
623
+ >>> metrics_summary() # doctest: +SKIP
624
+ """
625
+ records = [r for r in get_metrics() if r.number is not None]
626
+ if not records:
627
+ cfg = _get_config()
628
+ theme: dict[str, str] = cfg["theme"]
629
+ console = _get_console()
630
+ console.print("No metrics recorded (use step=True).", style=theme["muted"])
631
+ return
632
+
633
+ total_elapsed = sum(r.elapsed_seconds for r in records)
634
+ total_memory = sum(r.peak_memory_bytes or 0 for r in records)
635
+
636
+ if style == "simple":
637
+ _print_simple_summary(records, total_elapsed, total_memory, show_percentages)
638
+ else:
639
+ _print_table_summary(records, total_elapsed, total_memory, show_percentages)
640
+
641
+
642
+ def _print_simple_summary(
643
+ records: list[MetricsRecord],
644
+ total_elapsed: float,
645
+ total_memory: int,
646
+ show_percentages: bool,
647
+ ) -> None:
648
+ """Print a simple text summary using Rich."""
649
+ cfg = _get_config()
650
+ theme: dict[str, str] = cfg["theme"]
651
+ console = _get_console()
652
+
653
+ console.print()
654
+ console.print("=" * 50, style=theme["muted"])
655
+ console.print("METRICS SUMMARY", style=theme["title"])
656
+ console.print("=" * 50, style=theme["muted"])
657
+
658
+ for r in records:
659
+ pct = (r.elapsed_seconds / total_elapsed * 100) if total_elapsed > 0 else 0
660
+ time_style = _get_time_style(r.elapsed_seconds, cfg)
661
+
662
+ line = Text(" ")
663
+ line.append(f"[{r.number}]", style=theme["step_number"])
664
+ line.append(" ")
665
+ # Parse Rich markup in title (e.g., "[dim green]...[/dim green]")
666
+ title_text = Text.from_markup(r.title, style=theme["text"])
667
+ line.append(title_text)
668
+ line.append(": ")
669
+ line.append(r.elapsed_formatted, style=time_style)
670
+
671
+ if r.peak_memory_bytes:
672
+ mem_style = _get_memory_style(r.peak_memory_bytes, cfg)
673
+ line.append(f" ({r.peak_memory_formatted})", style=mem_style)
674
+ if show_percentages:
675
+ line.append(f" [{pct:5.1f}%]", style=theme["muted"])
676
+
677
+ console.print(line)
678
+
679
+ console.print("-" * 50, style=theme["muted"])
680
+ footer = Text(" ")
681
+ footer.append(f"TOTAL: {_format_time(total_elapsed)}", style=theme["label"])
682
+ if total_memory > 0:
683
+ footer.append(f" | {_format_bytes(total_memory)}", style=theme["memory_ok"])
684
+ console.print(footer)
685
+ console.print()
686
+
687
+
688
+ def _print_table_summary(
689
+ records: list[MetricsRecord],
690
+ total_elapsed: float,
691
+ total_memory: int,
692
+ show_percentages: bool,
693
+ ) -> None:
694
+ """Print a Rich table summary."""
695
+ try:
696
+ from kstlib.ui.tables import TableBuilder
697
+ except ImportError:
698
+ _print_simple_summary(records, total_elapsed, total_memory, show_percentages)
699
+ return
700
+
701
+ cfg = _get_config()
702
+ theme: dict[str, str] = cfg["theme"]
703
+ console = _get_console()
704
+
705
+ # Check if any record has memory
706
+ has_memory = any(r.peak_memory_bytes for r in records)
707
+
708
+ # Build columns
709
+ columns: list[dict[str, Any]] = [
710
+ {"header": "#", "key": "num", "justify": "right", "style": theme["step_number"], "width": 4},
711
+ {"header": "Step", "key": "title", "justify": "left", "style": theme["text"]},
712
+ {"header": "Time", "key": "time", "justify": "right", "style": theme["time_ok"]},
713
+ ]
714
+ if has_memory:
715
+ columns.append({"header": "Memory", "key": "memory", "justify": "right", "style": theme["memory_ok"]})
716
+ if show_percentages:
717
+ columns.append({"header": "%", "key": "pct", "justify": "right", "style": theme["muted"], "width": 6})
718
+
719
+ # Build rows
720
+ rows = []
721
+ for r in records:
722
+ pct = (r.elapsed_seconds / total_elapsed * 100) if total_elapsed > 0 else 0
723
+ # Extract plain text from title (strip Rich markup for table display)
724
+ plain_title = Text.from_markup(r.title).plain
725
+ row: dict[str, str] = {
726
+ "num": str(r.number),
727
+ "title": plain_title,
728
+ "time": r.elapsed_formatted,
729
+ }
730
+ if has_memory:
731
+ row["memory"] = r.peak_memory_formatted or "-"
732
+ if show_percentages:
733
+ row["pct"] = f"{pct:.1f}%"
734
+ rows.append(row)
735
+
736
+ # Render table
737
+ builder = TableBuilder()
738
+ table = builder.render_table(
739
+ data=rows,
740
+ columns=columns,
741
+ table={
742
+ "title": "Metrics Summary",
743
+ "box": "ROUNDED",
744
+ "header_style": theme["table_header"],
745
+ "show_lines": False,
746
+ },
747
+ )
748
+
749
+ console.print(table)
750
+
751
+ # Print footer using Rich Text
752
+ footer = Text(" ")
753
+ footer.append(f"TOTAL: {_format_time(total_elapsed)}", style=theme["label"])
754
+ if total_memory > 0:
755
+ footer.append(f" | {_format_bytes(total_memory)}", style=theme["memory_ok"])
756
+ if show_percentages:
757
+ footer.append(" (100%)", style=theme["muted"])
758
+ console.print(footer)
759
+
760
+
761
+ # =============================================================================
762
+ # Stopwatch (manual control)
763
+ # =============================================================================
764
+
765
+
766
+ @dataclass
767
+ class Stopwatch:
768
+ """Manual stopwatch for timing code sections.
769
+
770
+ For cases where you need explicit start/stop/lap control.
771
+
772
+ Examples:
773
+ >>> sw = Stopwatch("Pipeline")
774
+ >>> _ = sw.start()
775
+ >>> # ... work ...
776
+ >>> sw.lap("Step 1") # doctest: +SKIP
777
+ >>> _ = sw.stop()
778
+ >>> sw.summary() # doctest: +SKIP
779
+ """
780
+
781
+ name: str = "Stopwatch"
782
+ _start_time: float | None = field(default=None, repr=False)
783
+ _lap_start: float | None = field(default=None, repr=False)
784
+ _laps: list[tuple[str, float, int | None]] = field(default_factory=list, repr=False)
785
+ _stopped: bool = field(default=False, repr=False)
786
+ _lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
787
+
788
+ def start(self) -> Stopwatch:
789
+ """Start the stopwatch."""
790
+ with self._lock:
791
+ self._start_time = time_module.perf_counter()
792
+ self._lap_start = self._start_time
793
+ self._stopped = False
794
+ return self
795
+
796
+ def lap(self, name: str, *, print_result: bool = True, track_memory: bool = False) -> float:
797
+ """Record a lap time."""
798
+ with self._lock:
799
+ if self._lap_start is None:
800
+ self.start()
801
+ now = time_module.perf_counter()
802
+ elapsed = now - (self._lap_start or now)
803
+
804
+ peak = None
805
+ if track_memory and tracemalloc.is_tracing():
806
+ _, peak = tracemalloc.get_traced_memory()
807
+
808
+ self._laps.append((name, elapsed, peak))
809
+ self._lap_start = now
810
+
811
+ if print_result:
812
+ cfg = _get_config()
813
+ theme: dict[str, str] = cfg["theme"]
814
+ icons: dict[str, str] = cfg["icons"]
815
+ console = _get_console()
816
+ lap_num = len(self._laps)
817
+ time_style = _get_time_style(elapsed, cfg)
818
+
819
+ # Build label from lap_format
820
+ lap_format = cfg.get("lap_format", "[LAP {n}] {name}")
821
+ label = lap_format.format(n=lap_num, name=name)
822
+
823
+ output = Text()
824
+ output.append(label, style=theme["label"])
825
+ output.append(" ", style=theme["separator"])
826
+ time_icon = f"{icons['time']} " if icons["time"] else ""
827
+ output.append(f"{time_icon}{_format_time(elapsed)}", style=time_style)
828
+ console.print(output)
829
+
830
+ return elapsed
831
+
832
+ def stop(self) -> float:
833
+ """Stop the stopwatch."""
834
+ with self._lock:
835
+ self._stopped = True
836
+ if self._start_time is None:
837
+ return 0.0
838
+ return time_module.perf_counter() - self._start_time
839
+
840
+ @property
841
+ def total_elapsed(self) -> float:
842
+ """Get total elapsed time."""
843
+ with self._lock:
844
+ if self._start_time is None:
845
+ return 0.0
846
+ return time_module.perf_counter() - self._start_time
847
+
848
+ @property
849
+ def laps(self) -> list[tuple[str, float, int | None]]:
850
+ """Get all recorded laps."""
851
+ with self._lock:
852
+ return list(self._laps)
853
+
854
+ def reset(self) -> None:
855
+ """Reset the stopwatch."""
856
+ with self._lock:
857
+ self._start_time = None
858
+ self._lap_start = None
859
+ self._laps.clear()
860
+ self._stopped = False
861
+
862
+ def summary(self, *, show_percentages: bool = True) -> None:
863
+ """Print a summary of all laps."""
864
+ laps = self.laps
865
+ total = self.total_elapsed
866
+ cfg = _get_config()
867
+ theme: dict[str, str] = cfg["theme"]
868
+ console = _get_console()
869
+
870
+ if not laps:
871
+ console.print("No laps recorded.", style=theme["muted"])
872
+ return
873
+
874
+ console.print()
875
+ console.print("=" * 50, style=theme["muted"])
876
+ console.print(f"{self.name} SUMMARY", style=theme["title"])
877
+ console.print("=" * 50, style=theme["muted"])
878
+
879
+ for i, (name, elapsed, peak) in enumerate(laps, 1):
880
+ pct = (elapsed / total * 100) if total > 0 else 0
881
+ time_style = _get_time_style(elapsed, cfg)
882
+
883
+ line = Text(" ")
884
+ line.append(f"[{i}]", style=theme["step_number"])
885
+ line.append(" ")
886
+ line.append(name, style=theme["text"])
887
+ line.append(": ")
888
+ line.append(_format_time(elapsed), style=time_style)
889
+
890
+ if peak:
891
+ mem_style = _get_memory_style(peak, cfg)
892
+ line.append(f" ({_format_bytes(peak)})", style=mem_style)
893
+ if show_percentages:
894
+ line.append(f" [{pct:5.1f}%]", style=theme["muted"])
895
+
896
+ console.print(line)
897
+
898
+ console.print("-" * 50, style=theme["muted"])
899
+ footer = Text(" ")
900
+ footer.append(f"TOTAL: {_format_time(total)}", style=theme["label"])
901
+ console.print(footer)
902
+ console.print()
903
+
904
+
905
+ # =============================================================================
906
+ # Call stats (for tracking multiple calls)
907
+ # =============================================================================
908
+
909
+
910
+ @dataclass
911
+ class CallStats:
912
+ """Statistics for tracked function calls.
913
+
914
+ Examples:
915
+ >>> stats = CallStats("my_func")
916
+ >>> stats.record(0.5)
917
+ >>> stats.record(1.0)
918
+ >>> stats.call_count
919
+ 2
920
+ >>> stats.avg_time
921
+ 0.75
922
+ """
923
+
924
+ name: str
925
+ call_count: int = 0
926
+ total_time: float = 0.0
927
+ min_time: float = field(default=float("inf"))
928
+ max_time: float = 0.0
929
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
930
+
931
+ def record(self, elapsed: float) -> None:
932
+ """Record a call duration."""
933
+ with self._lock:
934
+ self.call_count += 1
935
+ self.total_time += elapsed
936
+ self.min_time = min(self.min_time, elapsed)
937
+ self.max_time = max(self.max_time, elapsed)
938
+
939
+ @property
940
+ def avg_time(self) -> float:
941
+ """Return average call duration."""
942
+ if self.call_count == 0:
943
+ return 0.0
944
+ return self.total_time / self.call_count
945
+
946
+ def reset(self) -> None:
947
+ """Reset all statistics."""
948
+ with self._lock:
949
+ self.call_count = 0
950
+ self.total_time = 0.0
951
+ self.min_time = float("inf")
952
+ self.max_time = 0.0
953
+
954
+ def __str__(self) -> str:
955
+ """Return human-readable summary with Rich colors."""
956
+ if self.call_count == 0:
957
+ return f"[{self.name}] No calls recorded"
958
+
959
+ cfg = _get_config()
960
+ theme: dict[str, str] = cfg["theme"]
961
+
962
+ # Build Rich Text with styled parts
963
+ text = Text()
964
+ text.append(f"[{self.name}]", style=theme["label"])
965
+ text.append(f" {self.call_count} calls", style=theme["label"])
966
+ text.append(" | ", style=theme["separator"])
967
+ text.append(f"avg: {_format_time(self.avg_time)}", style=theme["time_ok"])
968
+ text.append(" | ", style=theme["separator"])
969
+ text.append(f"min: {_format_time(self.min_time)}", style=theme["muted"])
970
+ text.append(" | ", style=theme["separator"])
971
+ text.append(f"max: {_format_time(self.max_time)}", style=theme["muted"])
972
+
973
+ # Render to ANSI string
974
+ from io import StringIO
975
+
976
+ buffer = StringIO()
977
+ console = Console(file=buffer, force_terminal=cfg["colors"], no_color=not cfg["colors"])
978
+ console.print(text, end="")
979
+ return buffer.getvalue()
980
+
981
+
982
+ _call_stats_registry: dict[str, CallStats] = {}
983
+ _registry_lock = threading.Lock()
984
+
985
+
986
+ def get_call_stats(func_name: str) -> CallStats | None:
987
+ """Get call statistics for a tracked function."""
988
+ with _registry_lock:
989
+ return _call_stats_registry.get(func_name)
990
+
991
+
992
+ def get_all_call_stats() -> dict[str, CallStats]:
993
+ """Get all tracked call statistics."""
994
+ with _registry_lock:
995
+ return dict(_call_stats_registry)
996
+
997
+
998
+ def reset_all_call_stats() -> None:
999
+ """Reset all tracked call statistics."""
1000
+ with _registry_lock:
1001
+ for stats in _call_stats_registry.values():
1002
+ stats.reset()
1003
+
1004
+
1005
+ def print_all_call_stats() -> None:
1006
+ """Print all tracked call statistics to stderr."""
1007
+ with _registry_lock:
1008
+ for stats in _call_stats_registry.values():
1009
+ if stats.call_count > 0:
1010
+ print(str(stats), file=sys.stderr)
1011
+
1012
+
1013
+ @overload
1014
+ def call_stats(func: Callable[P, R], /) -> Callable[P, R]: ...
1015
+
1016
+
1017
+ @overload
1018
+ def call_stats(
1019
+ func: None = None,
1020
+ /,
1021
+ *,
1022
+ name: str | None = None,
1023
+ print_on_call: bool = False,
1024
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
1025
+
1026
+
1027
+ def call_stats(
1028
+ func: Callable[P, R] | None = None,
1029
+ /,
1030
+ *,
1031
+ name: str | None = None,
1032
+ print_on_call: bool = False,
1033
+ ) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
1034
+ """Decorator to track call statistics.
1035
+
1036
+ Tracks call count, total time, min, max, and average duration.
1037
+ Use get_call_stats() or print_all_call_stats() to access results.
1038
+
1039
+ Examples:
1040
+ >>> @call_stats
1041
+ ... def api_call():
1042
+ ... pass
1043
+ >>> api_call()
1044
+ >>> api_call()
1045
+ >>> stats = get_call_stats("api_call")
1046
+ >>> stats.call_count
1047
+ 2
1048
+ """
1049
+
1050
+ def decorator(fn: Callable[P, R]) -> Callable[P, R]:
1051
+ label = name or fn.__name__
1052
+
1053
+ with _registry_lock:
1054
+ if label not in _call_stats_registry:
1055
+ _call_stats_registry[label] = CallStats(name=label)
1056
+ stats = _call_stats_registry[label]
1057
+
1058
+ @functools.wraps(fn)
1059
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
1060
+ start = time_module.perf_counter()
1061
+ try:
1062
+ return fn(*args, **kwargs)
1063
+ finally:
1064
+ elapsed = time_module.perf_counter() - start
1065
+ stats.record(elapsed)
1066
+ if print_on_call:
1067
+ print(str(stats), file=sys.stderr)
1068
+
1069
+ return wrapper
1070
+
1071
+ if func is not None:
1072
+ return decorator(func)
1073
+ return decorator
1074
+
1075
+
1076
+ __all__ = [
1077
+ "CallStats",
1078
+ "MetricsRecord",
1079
+ "Stopwatch",
1080
+ "call_stats",
1081
+ "clear_metrics",
1082
+ "get_all_call_stats",
1083
+ "get_call_stats",
1084
+ "get_metrics",
1085
+ "metrics",
1086
+ "metrics_context",
1087
+ "metrics_summary",
1088
+ "print_all_call_stats",
1089
+ "reset_all_call_stats",
1090
+ ]