kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.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
|
+
]
|