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.
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.1.dist-info/METADATA +201 -0
  159. kstlib-1.0.1.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.1.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.1.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.1.dist-info}/top_level.txt +0 -0
kstlib/ui/spinner.py ADDED
@@ -0,0 +1,864 @@
1
+ """Animated spinner utilities for CLI feedback during long operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import io
7
+ import sys
8
+ import threading
9
+ import time
10
+ from collections import deque
11
+ from enum import Enum
12
+ from typing import IO, TYPE_CHECKING, Any, ParamSpec, TypeVar
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable
16
+
17
+ from rich.console import Console
18
+ from rich.text import Text
19
+ from typing_extensions import Self
20
+
21
+ from kstlib.config import ConfigNotLoadedError, get_config
22
+ from kstlib.ui.exceptions import SpinnerError
23
+
24
+ if TYPE_CHECKING:
25
+ import types
26
+
27
+ from rich.style import Style
28
+
29
+ P = ParamSpec("P")
30
+ R = TypeVar("R")
31
+
32
+
33
+ class SpinnerStyle(Enum):
34
+ """Predefined spinner animation families.
35
+
36
+ Each style defines a sequence of frames that cycle during animation.
37
+ """
38
+
39
+ BRAILLE = ("⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷")
40
+ DOTS = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
41
+ LINE = ("|", "/", "-", "\\")
42
+ ARROW = ("←", "↖", "↑", "↗", "→", "↘", "↓", "↙")
43
+ BLOCKS = ("▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂")
44
+ CIRCLE = ("◐", "◓", "◑", "◒")
45
+ SQUARE = ("◰", "◳", "◲", "◱")
46
+ MOON = ("🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘")
47
+ CLOCK = ("🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛")
48
+
49
+
50
+ class SpinnerPosition(Enum):
51
+ """Position of the spinner relative to the message text."""
52
+
53
+ BEFORE = "before"
54
+ AFTER = "after"
55
+
56
+
57
+ class SpinnerAnimationType(Enum):
58
+ """Type of animation to display."""
59
+
60
+ SPIN = "spin"
61
+ BOUNCE = "bounce"
62
+ COLOR_WAVE = "color_wave"
63
+
64
+
65
+ # Default configuration
66
+ DEFAULT_SPINNER_CONFIG: dict[str, Any] = {
67
+ "defaults": {
68
+ "style": "BRAILLE",
69
+ "position": "before",
70
+ "animation_type": "spin",
71
+ "interval": 0.08,
72
+ "spinner_style": "cyan",
73
+ "text_style": None,
74
+ "done_character": "✓",
75
+ "done_style": "green",
76
+ "fail_character": "✗",
77
+ "fail_style": "red",
78
+ },
79
+ "presets": {
80
+ "minimal": {
81
+ "style": "LINE",
82
+ "spinner_style": "dim white",
83
+ "interval": 0.1,
84
+ },
85
+ "fancy": {
86
+ "style": "BRAILLE",
87
+ "spinner_style": "bold cyan",
88
+ "interval": 0.06,
89
+ },
90
+ "blocks": {
91
+ "style": "BLOCKS",
92
+ "spinner_style": "blue",
93
+ "interval": 0.05,
94
+ },
95
+ "bounce": {
96
+ "animation_type": "bounce",
97
+ "interval": 0.08,
98
+ "spinner_style": "yellow",
99
+ },
100
+ "color_wave": {
101
+ "animation_type": "color_wave",
102
+ "interval": 0.1,
103
+ },
104
+ },
105
+ }
106
+
107
+ # Color wave palette
108
+ COLOR_WAVE_COLORS = [
109
+ "bright_blue",
110
+ "cyan",
111
+ "bright_cyan",
112
+ "white",
113
+ "bright_cyan",
114
+ "cyan",
115
+ ]
116
+
117
+ # Bounce bar width
118
+ BOUNCE_WIDTH = 20
119
+ BOUNCE_CHAR = "="
120
+ BOUNCE_BRACKET_LEFT = "["
121
+ BOUNCE_BRACKET_RIGHT = "]"
122
+
123
+
124
+ def _load_spinner_config() -> dict[str, Any]:
125
+ """Load spinner configuration from kstlib.conf.yml with fallback to defaults."""
126
+ try:
127
+ config = get_config().to_dict()
128
+ ui_config = config.get("ui", {})
129
+ spinners_config = ui_config.get("spinners", {})
130
+ if spinners_config:
131
+ # Merge with defaults to ensure all keys exist
132
+ merged: dict[str, Any] = {
133
+ "defaults": {**DEFAULT_SPINNER_CONFIG["defaults"]},
134
+ "presets": {**DEFAULT_SPINNER_CONFIG["presets"]},
135
+ }
136
+ if "defaults" in spinners_config:
137
+ merged["defaults"].update(spinners_config["defaults"])
138
+ if "presets" in spinners_config:
139
+ for preset_name, preset_vals in spinners_config["presets"].items():
140
+ if preset_name in merged["presets"]:
141
+ merged["presets"][preset_name].update(preset_vals)
142
+ else:
143
+ merged["presets"][preset_name] = dict(preset_vals)
144
+ return merged
145
+ except ConfigNotLoadedError:
146
+ pass
147
+ return DEFAULT_SPINNER_CONFIG
148
+
149
+
150
+ class Spinner:
151
+ """Animated spinner for CLI feedback during long operations.
152
+
153
+ Supports multiple animation styles including character spinners, bouncing bars,
154
+ and color wave effects. Can be used as a context manager or controlled manually.
155
+
156
+ Args:
157
+ message: Text to display alongside the spinner.
158
+ style: Spinner animation style (SpinnerStyle enum or string name).
159
+ position: Where to place spinner relative to text (before/after).
160
+ animation_type: Type of animation (spin/bounce/color_wave).
161
+ interval: Seconds between animation frames.
162
+ spinner_style: Rich style for the spinner character.
163
+ text_style: Rich style for the message text.
164
+ console: Optional Rich console instance.
165
+ file: Output stream (defaults to sys.stderr).
166
+
167
+ Examples:
168
+ Create a spinner with default settings:
169
+
170
+ >>> spinner = Spinner("Loading...")
171
+ >>> spinner.message
172
+ 'Loading...'
173
+
174
+ Create with custom style:
175
+
176
+ >>> spinner = Spinner("Working", style=SpinnerStyle.DOTS)
177
+ >>> spinner = Spinner("Building", style="BLOCKS", interval=0.1)
178
+
179
+ Using as a context manager (terminal I/O):
180
+
181
+ >>> with Spinner("Processing...") as s: # doctest: +SKIP
182
+ ... do_long_operation()
183
+ ... s.update("Almost done...")
184
+
185
+ Manual control:
186
+
187
+ >>> spinner = Spinner("Working...") # doctest: +SKIP
188
+ >>> spinner.start() # doctest: +SKIP
189
+ >>> spinner.stop(success=True) # doctest: +SKIP
190
+ """
191
+
192
+ def __init__(
193
+ self,
194
+ message: str = "",
195
+ *,
196
+ style: SpinnerStyle | str = SpinnerStyle.BRAILLE,
197
+ position: SpinnerPosition | str = SpinnerPosition.BEFORE,
198
+ animation_type: SpinnerAnimationType | str = SpinnerAnimationType.SPIN,
199
+ interval: float = 0.08,
200
+ spinner_style: str | Style | None = "cyan",
201
+ text_style: str | Style | None = None,
202
+ done_character: str = "✓",
203
+ done_style: str | Style | None = "green",
204
+ fail_character: str = "✗",
205
+ fail_style: str | Style | None = "red",
206
+ console: Console | None = None,
207
+ file: IO[str] | None = None,
208
+ ) -> None:
209
+ """Initialize spinner with configuration."""
210
+ self._message = message
211
+ self._style = self._resolve_style(style)
212
+ self._position = self._resolve_position(position)
213
+ self._animation_type = self._resolve_animation_type(animation_type)
214
+ self._interval = interval
215
+ self._spinner_style = spinner_style
216
+ self._text_style = text_style
217
+ self._done_character = done_character
218
+ self._done_style = done_style
219
+ self._fail_character = fail_character
220
+ self._fail_style = fail_style
221
+ self._file = file or sys.stderr
222
+ self._console = console or Console(file=self._file, force_terminal=True)
223
+
224
+ self._running = False
225
+ self._thread: threading.Thread | None = None
226
+ self._lock = threading.Lock()
227
+ self._frame_index = 0
228
+ self._bounce_position = 0
229
+ self._bounce_direction = 1
230
+ self._color_offset = 0
231
+
232
+ @classmethod
233
+ def from_preset(
234
+ cls,
235
+ preset: str,
236
+ message: str = "",
237
+ *,
238
+ console: Console | None = None,
239
+ **overrides: Any,
240
+ ) -> Spinner:
241
+ """Create a spinner from a named preset.
242
+
243
+ Args:
244
+ preset: Name of the preset (e.g., "minimal", "fancy", "bounce").
245
+ message: Text to display alongside the spinner.
246
+ console: Optional Rich console instance.
247
+ **overrides: Additional parameters to override preset values.
248
+
249
+ Returns:
250
+ Configured Spinner instance.
251
+
252
+ Raises:
253
+ SpinnerError: If preset name is not found.
254
+
255
+ Examples:
256
+ Create from a built-in preset:
257
+
258
+ >>> spinner = Spinner.from_preset("minimal", "Loading...")
259
+ >>> spinner = Spinner.from_preset("fancy", "Processing data")
260
+
261
+ Override preset values:
262
+
263
+ >>> spinner = Spinner.from_preset("bounce", "Building", interval=0.05)
264
+
265
+ Invalid preset raises error:
266
+
267
+ >>> Spinner.from_preset("nonexistent") # doctest: +ELLIPSIS
268
+ Traceback (most recent call last):
269
+ ...
270
+ kstlib.ui.exceptions.SpinnerError: Unknown preset 'nonexistent'. ...
271
+ """
272
+ config = _load_spinner_config()
273
+ presets = config.get("presets", {})
274
+ if preset not in presets:
275
+ available = ", ".join(presets.keys())
276
+ raise SpinnerError(f"Unknown preset '{preset}'. Available: {available}")
277
+
278
+ defaults = config.get("defaults", {}).copy()
279
+ preset_config = presets[preset].copy()
280
+ merged = {**defaults, **preset_config, **overrides}
281
+
282
+ return cls(
283
+ message,
284
+ style=merged.get("style", SpinnerStyle.BRAILLE),
285
+ position=merged.get("position", SpinnerPosition.BEFORE),
286
+ animation_type=merged.get("animation_type", SpinnerAnimationType.SPIN),
287
+ interval=merged.get("interval", 0.08),
288
+ spinner_style=merged.get("spinner_style"),
289
+ text_style=merged.get("text_style"),
290
+ done_character=merged.get("done_character", "✓"),
291
+ done_style=merged.get("done_style", "green"),
292
+ fail_character=merged.get("fail_character", "✗"),
293
+ fail_style=merged.get("fail_style", "red"),
294
+ console=console,
295
+ )
296
+
297
+ def start(self) -> None:
298
+ """Start the spinner animation in a background thread."""
299
+ if self._running:
300
+ return
301
+
302
+ self._running = True
303
+ self._hide_cursor()
304
+ self._thread = threading.Thread(target=self._animate, daemon=True)
305
+ self._thread.start()
306
+
307
+ def stop(self, *, success: bool = True, final_message: str | None = None) -> None:
308
+ """Stop the spinner animation.
309
+
310
+ Args:
311
+ success: If True, show done character; if False, show fail character.
312
+ final_message: Optional message to display after stopping.
313
+ """
314
+ if not self._running:
315
+ return
316
+
317
+ self._running = False
318
+ if self._thread is not None:
319
+ self._thread.join(timeout=1.0)
320
+ self._thread = None
321
+
322
+ self._clear_line()
323
+ self._show_cursor()
324
+ self._render_final(success=success, final_message=final_message)
325
+
326
+ def update(self, message: str) -> None:
327
+ """Update the spinner message while running.
328
+
329
+ Args:
330
+ message: New message to display.
331
+ """
332
+ with self._lock:
333
+ self._message = message
334
+
335
+ def log(self, message: str, style: str | None = None) -> None:
336
+ """Print a message above the spinner without disrupting animation.
337
+
338
+ Use this to display logs, progress info, or any output while the
339
+ spinner continues running on the bottom line.
340
+
341
+ Args:
342
+ message: Text to print above the spinner.
343
+ style: Optional Rich style for the message.
344
+ """
345
+ with self._lock:
346
+ # Clear spinner line
347
+ self._file.write("\r\033[K")
348
+ self._file.flush()
349
+ # Print the log message
350
+ if style:
351
+ self._console.print(f"[{style}]{message}[/{style}]")
352
+ else:
353
+ self._console.print(message)
354
+ # Spinner will redraw on next frame
355
+
356
+ @property
357
+ def message(self) -> str:
358
+ """Current spinner message."""
359
+ with self._lock:
360
+ return self._message
361
+
362
+ def __enter__(self) -> Self:
363
+ """Start spinner when entering context."""
364
+ self.start()
365
+ return self
366
+
367
+ def __exit__(
368
+ self,
369
+ exc_type: type[BaseException] | None,
370
+ exc_val: BaseException | None,
371
+ exc_tb: types.TracebackType | None,
372
+ ) -> None:
373
+ """Stop spinner when exiting context."""
374
+ success = exc_type is None
375
+ self.stop(success=success)
376
+
377
+ # ------------------------------------------------------------------
378
+ # Animation loop
379
+ # ------------------------------------------------------------------
380
+
381
+ def _animate(self) -> None:
382
+ """Main animation loop running in background thread."""
383
+ while self._running:
384
+ self._render_frame()
385
+ time.sleep(self._interval)
386
+
387
+ def _render_frame(self) -> None:
388
+ """Render a single animation frame."""
389
+ self._move_to_line_start()
390
+
391
+ if self._animation_type == SpinnerAnimationType.SPIN:
392
+ self._render_spin_frame()
393
+ elif self._animation_type == SpinnerAnimationType.BOUNCE:
394
+ self._render_bounce_frame()
395
+ elif self._animation_type == SpinnerAnimationType.COLOR_WAVE:
396
+ self._render_color_wave_frame()
397
+
398
+ def _render_spin_frame(self) -> None:
399
+ """Render classic spinner animation frame."""
400
+ frames = self._style.value
401
+ frame_char = frames[self._frame_index % len(frames)]
402
+ self._frame_index += 1
403
+
404
+ spinner_text = Text(frame_char, style=self._spinner_style or "")
405
+
406
+ with self._lock:
407
+ message = self._message
408
+
409
+ message_text = self._styled_message(message)
410
+ if self._position == SpinnerPosition.BEFORE:
411
+ output = Text.assemble(spinner_text, " ", message_text)
412
+ else:
413
+ output = Text.assemble(message_text, " ", spinner_text)
414
+
415
+ self._console.print(output, end="")
416
+
417
+ def _render_bounce_frame(self) -> None:
418
+ """Render bouncing bar animation frame."""
419
+ # Build the bar: [= ] with = bouncing
420
+ bar_inner = [" "] * BOUNCE_WIDTH
421
+ bar_inner[self._bounce_position] = BOUNCE_CHAR
422
+
423
+ # Update bounce position
424
+ self._bounce_position += self._bounce_direction
425
+ if self._bounce_position >= BOUNCE_WIDTH - 1:
426
+ self._bounce_direction = -1
427
+ elif self._bounce_position <= 0:
428
+ self._bounce_direction = 1
429
+
430
+ bar = BOUNCE_BRACKET_LEFT + "".join(bar_inner) + BOUNCE_BRACKET_RIGHT
431
+ bar_text = Text(bar, style=self._spinner_style or "")
432
+
433
+ with self._lock:
434
+ message = self._message
435
+
436
+ message_text = self._styled_message(message)
437
+ if self._position == SpinnerPosition.BEFORE:
438
+ output = Text.assemble(bar_text, " ", message_text)
439
+ else:
440
+ output = Text.assemble(message_text, " ", bar_text)
441
+
442
+ self._console.print(output, end="")
443
+
444
+ def _render_color_wave_frame(self) -> None:
445
+ """Render color wave animation through text."""
446
+ with self._lock:
447
+ message = self._message
448
+
449
+ if not message:
450
+ return
451
+
452
+ output = Text()
453
+ for i, char in enumerate(message):
454
+ color_index = (i + self._color_offset) % len(COLOR_WAVE_COLORS)
455
+ output.append(char, style=COLOR_WAVE_COLORS[color_index])
456
+
457
+ self._color_offset += 1
458
+ self._console.print(output, end="")
459
+
460
+ def _render_final(self, *, success: bool, final_message: str | None) -> None:
461
+ """Render final state after stopping."""
462
+ if self._animation_type == SpinnerAnimationType.COLOR_WAVE:
463
+ # For color wave, just print the message normally
464
+ with self._lock:
465
+ message = final_message or self._message
466
+ if message:
467
+ style = self._done_style if success else self._fail_style
468
+ self._console.print(Text(message, style=style or ""))
469
+ return
470
+
471
+ char = self._done_character if success else self._fail_character
472
+ char_style = self._done_style if success else self._fail_style
473
+ final_char = Text(char, style=char_style or "")
474
+
475
+ with self._lock:
476
+ message = final_message or self._message
477
+
478
+ message_text = self._styled_message(message)
479
+ if self._position == SpinnerPosition.BEFORE:
480
+ output = Text.assemble(final_char, " ", message_text)
481
+ else:
482
+ output = Text.assemble(message_text, " ", final_char)
483
+
484
+ self._console.print(output)
485
+
486
+ def _styled_message(self, message: str) -> Text:
487
+ """Create a styled Text object for the message."""
488
+ if self._text_style:
489
+ return Text(message, style=self._text_style)
490
+ return Text(message)
491
+
492
+ def _move_to_line_start(self) -> None:
493
+ """Move cursor to beginning of line without clearing."""
494
+ self._file.write("\r")
495
+ self._file.flush()
496
+
497
+ def _clear_line(self) -> None:
498
+ """Clear the current console line."""
499
+ # Write ANSI sequences directly to file descriptor (Rich doesn't pass them through)
500
+ self._file.write("\r\033[K")
501
+ self._file.flush()
502
+
503
+ def _hide_cursor(self) -> None:
504
+ """Hide terminal cursor to reduce flickering."""
505
+ self._file.write("\033[?25l")
506
+ self._file.flush()
507
+
508
+ def _show_cursor(self) -> None:
509
+ """Show terminal cursor."""
510
+ self._file.write("\033[?25h")
511
+ self._file.flush()
512
+
513
+ # ------------------------------------------------------------------
514
+ # Resolution helpers
515
+ # ------------------------------------------------------------------
516
+
517
+ @staticmethod
518
+ def _resolve_style(style: SpinnerStyle | str) -> SpinnerStyle:
519
+ """Convert string to SpinnerStyle enum if needed."""
520
+ if isinstance(style, SpinnerStyle):
521
+ return style
522
+ try:
523
+ return SpinnerStyle[style.upper()]
524
+ except KeyError as exc:
525
+ available = ", ".join(s.name for s in SpinnerStyle)
526
+ raise SpinnerError(f"Unknown spinner style '{style}'. Available: {available}") from exc
527
+
528
+ @staticmethod
529
+ def _resolve_position(position: SpinnerPosition | str) -> SpinnerPosition:
530
+ """Convert string to SpinnerPosition enum if needed."""
531
+ if isinstance(position, SpinnerPosition):
532
+ return position
533
+ try:
534
+ return SpinnerPosition(position.lower())
535
+ except ValueError as exc:
536
+ raise SpinnerError(f"Invalid position '{position}'. Use 'before' or 'after'.") from exc
537
+
538
+ @staticmethod
539
+ def _resolve_animation_type(animation_type: SpinnerAnimationType | str) -> SpinnerAnimationType:
540
+ """Convert string to SpinnerAnimationType enum if needed."""
541
+ if isinstance(animation_type, SpinnerAnimationType):
542
+ return animation_type
543
+ try:
544
+ return SpinnerAnimationType(animation_type.lower())
545
+ except ValueError as exc:
546
+ available = ", ".join(t.value for t in SpinnerAnimationType)
547
+ raise SpinnerError(f"Invalid animation type '{animation_type}'. Available: {available}") from exc
548
+
549
+
550
+ # ==============================================================================
551
+ # Decorator for capturing prints
552
+ # ==============================================================================
553
+
554
+
555
+ class _PrintCapture(io.StringIO):
556
+ """Captures print output and redirects to spinner.log()."""
557
+
558
+ def __init__(
559
+ self,
560
+ spinner: Spinner | SpinnerWithLogZone,
561
+ style: str | None = None,
562
+ ) -> None:
563
+ super().__init__()
564
+ self._spinner: Spinner | SpinnerWithLogZone = spinner
565
+ self._style = style
566
+
567
+ def write(self, text: str) -> int:
568
+ """Intercept write calls and send to spinner.log()."""
569
+ # Filter out empty strings and lone newlines
570
+ stripped = text.rstrip("\n")
571
+ if stripped:
572
+ self._spinner.log(stripped, style=self._style)
573
+ return len(text)
574
+
575
+
576
+ def with_spinner(
577
+ message: str = "Processing...",
578
+ *,
579
+ style: SpinnerStyle | str = SpinnerStyle.BRAILLE,
580
+ log_style: str | None = "dim",
581
+ capture_prints: bool = True,
582
+ log_zone_height: int | None = None,
583
+ **spinner_kwargs: Any,
584
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
585
+ """Decorator that wraps a function with a spinner, capturing its prints.
586
+
587
+ Args:
588
+ message: Spinner message to display.
589
+ style: Spinner animation style.
590
+ log_style: Style for captured print output (None for no style).
591
+ capture_prints: If True, redirect stdout to spinner.log().
592
+ log_zone_height: If set, use SpinnerWithLogZone with fixed height.
593
+ The spinner stays at top, logs scroll in bounded zone below.
594
+ **spinner_kwargs: Additional arguments passed to Spinner.
595
+
596
+ Returns:
597
+ Decorated function.
598
+
599
+ Examples:
600
+ Basic decorator usage (terminal I/O):
601
+
602
+ >>> @with_spinner("Loading data...") # doctest: +SKIP
603
+ ... def load_data():
604
+ ... return {"data": [1, 2, 3]}
605
+ >>> result = load_data() # doctest: +SKIP
606
+
607
+ With log capture (prints appear above spinner):
608
+
609
+ >>> @with_spinner("Processing...", log_style="cyan") # doctest: +SKIP
610
+ ... def process():
611
+ ... print("Step 1 complete") # Appears above spinner
612
+ ... print("Step 2 complete")
613
+ ... return True
614
+
615
+ Fixed log zone with bounded scrolling:
616
+
617
+ >>> @with_spinner("Building...", log_zone_height=5) # doctest: +SKIP
618
+ ... def build():
619
+ ... for i in range(10):
620
+ ... print(f"Step {i}") # Scrolls in 5-line zone
621
+ ... return True
622
+ """
623
+
624
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
625
+ @functools.wraps(func)
626
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
627
+ # Choose spinner type based on log_zone_height
628
+ if log_zone_height is not None:
629
+ spinner: Spinner | SpinnerWithLogZone = SpinnerWithLogZone(
630
+ message,
631
+ style=style,
632
+ log_zone_height=log_zone_height,
633
+ **spinner_kwargs,
634
+ )
635
+ else:
636
+ spinner = Spinner(message, style=style, **spinner_kwargs)
637
+
638
+ with spinner:
639
+ if capture_prints:
640
+ capture = _PrintCapture(spinner, style=log_style)
641
+ old_stdout = sys.stdout
642
+ sys.stdout = capture
643
+ try:
644
+ return func(*args, **kwargs)
645
+ finally:
646
+ sys.stdout = old_stdout
647
+ else:
648
+ return func(*args, **kwargs)
649
+
650
+ return wrapper
651
+
652
+ return decorator
653
+
654
+
655
+ # ==============================================================================
656
+ # Spinner with fixed log zone
657
+ # ==============================================================================
658
+
659
+
660
+ class SpinnerWithLogZone:
661
+ """Spinner with a fixed position and a scrollable log zone.
662
+
663
+ The spinner stays fixed at the top while logs scroll in a zone below.
664
+ When the zone is full, old logs are pushed out automatically.
665
+
666
+ Args:
667
+ message: Spinner message.
668
+ log_zone_height: Number of lines for the log zone (default 10).
669
+ style: Spinner animation style.
670
+ spinner_style: Rich style for spinner character.
671
+ console: Optional Rich console.
672
+ **kwargs: Additional Spinner arguments.
673
+
674
+ Examples:
675
+ Create with custom log zone height:
676
+
677
+ >>> sz = SpinnerWithLogZone("Building...", log_zone_height=5)
678
+ >>> sz._log_zone_height
679
+ 5
680
+
681
+ Usage as context manager (terminal I/O):
682
+
683
+ >>> with SpinnerWithLogZone("Processing", log_zone_height=3) as sz: # doctest: +SKIP
684
+ ... sz.log("Step 1 done")
685
+ ... sz.log("Step 2 done")
686
+ ... sz.update("Almost finished...")
687
+ """
688
+
689
+ def __init__(
690
+ self,
691
+ message: str = "",
692
+ *,
693
+ log_zone_height: int = 10,
694
+ style: SpinnerStyle | str = SpinnerStyle.BRAILLE,
695
+ spinner_style: str | None = "cyan",
696
+ console: Console | None = None,
697
+ file: IO[str] | None = None,
698
+ interval: float = 0.08,
699
+ ) -> None:
700
+ self._message = message
701
+ self._log_zone_height = log_zone_height
702
+ self._style = Spinner._resolve_style(style)
703
+ self._spinner_style = spinner_style
704
+ self._interval = interval
705
+ self._file = file or sys.stderr
706
+ self._console = console or Console(file=self._file, force_terminal=True)
707
+
708
+ self._logs: deque[str] = deque(maxlen=log_zone_height)
709
+ self._running = False
710
+ self._thread: threading.Thread | None = None
711
+ self._lock = threading.Lock()
712
+ self._frame_index = 0
713
+ self._initialized = False
714
+ self._logs_dirty = False # Track if logs need redraw
715
+ self._last_message = "" # Track message changes
716
+
717
+ def start(self) -> None:
718
+ """Start the spinner animation."""
719
+ if self._running:
720
+ return
721
+
722
+ self._running = True
723
+ self._setup_zone()
724
+ self._hide_cursor()
725
+ self._thread = threading.Thread(target=self._animate, daemon=True)
726
+ self._thread.start()
727
+
728
+ def stop(self, *, success: bool = True, final_message: str | None = None) -> None:
729
+ """Stop the spinner and clean up the display."""
730
+ if not self._running:
731
+ return
732
+
733
+ self._running = False
734
+ if self._thread is not None:
735
+ self._thread.join(timeout=1.0)
736
+ self._thread = None
737
+
738
+ self._show_cursor()
739
+ self._render_final(success, final_message)
740
+
741
+ def update(self, message: str) -> None:
742
+ """Update the spinner message."""
743
+ with self._lock:
744
+ self._message = message
745
+
746
+ def log(self, message: str, style: str | None = None) -> None:
747
+ """Add a log entry to the scrolling zone."""
748
+ with self._lock:
749
+ if style:
750
+ self._logs.append(f"[{style}]{message}[/{style}]")
751
+ else:
752
+ self._logs.append(message)
753
+ self._logs_dirty = True
754
+
755
+ def __enter__(self) -> Self:
756
+ """Start spinner when entering context."""
757
+ self.start()
758
+ return self
759
+
760
+ def __exit__(
761
+ self,
762
+ exc_type: type[BaseException] | None,
763
+ exc_val: BaseException | None,
764
+ exc_tb: types.TracebackType | None,
765
+ ) -> None:
766
+ """Stop spinner when exiting context."""
767
+ self.stop(success=exc_type is None)
768
+
769
+ def _setup_zone(self) -> None:
770
+ """Reserve space for the log zone by printing newlines."""
771
+ if self._initialized:
772
+ return
773
+ # Print empty lines to reserve space
774
+ # +1 for spinner line at top
775
+ self._file.write("\n" * (self._log_zone_height + 1))
776
+ self._file.flush()
777
+ self._initialized = True
778
+
779
+ def _animate(self) -> None:
780
+ """Animation loop."""
781
+ while self._running:
782
+ self._render_frame()
783
+ time.sleep(self._interval)
784
+
785
+ def _render_frame(self) -> None:
786
+ """Render spinner and log zone (optimized: only redraw what changed)."""
787
+ frames = self._style.value
788
+ frame_char = frames[self._frame_index % len(frames)]
789
+ self._frame_index += 1
790
+
791
+ with self._lock:
792
+ message = self._message
793
+ logs_dirty = self._logs_dirty
794
+ logs = list(self._logs) if logs_dirty else []
795
+ message_changed = message != self._last_message
796
+ self._logs_dirty = False
797
+ self._last_message = message
798
+
799
+ # Move cursor to spinner line (top of zone)
800
+ total_lines = self._log_zone_height + 1
801
+ self._file.write(f"\033[{total_lines}A") # Move up
802
+
803
+ # Always render spinner line (just overwrite, no clear needed for spinner char)
804
+ self._file.write("\r")
805
+ spinner_text = Text(frame_char, style=self._spinner_style or "")
806
+ msg_text = Text(f" {message}")
807
+ self._console.print(Text.assemble(spinner_text, msg_text), end="")
808
+
809
+ # Pad with spaces if message got shorter
810
+ if message_changed:
811
+ self._file.write("\033[K") # Clear rest of line
812
+
813
+ # Only redraw logs if they changed
814
+ if logs_dirty:
815
+ self._file.write("\n") # Move to first log line
816
+ for i in range(self._log_zone_height):
817
+ self._file.write("\033[K") # Clear line
818
+ if i < len(logs):
819
+ self._console.print(f" {logs[i]}", end="")
820
+ if i < self._log_zone_height - 1:
821
+ self._file.write("\n")
822
+ self._file.write("\n") # Final newline to position cursor at bottom
823
+ else:
824
+ # Just move cursor back to bottom without redrawing logs
825
+ self._file.write(f"\033[{self._log_zone_height}B") # Move down
826
+ self._file.write("\n")
827
+
828
+ self._file.flush()
829
+
830
+ def _render_final(self, success: bool, final_message: str | None) -> None:
831
+ """Render final state."""
832
+ char = "✓" if success else "✗"
833
+ char_style = "green" if success else "red"
834
+ message = final_message or self._message
835
+
836
+ with self._lock:
837
+ logs = list(self._logs)
838
+
839
+ # Move to top of zone
840
+ total_lines = self._log_zone_height + 1
841
+ self._file.write(f"\033[{total_lines}A")
842
+
843
+ # Final spinner line
844
+ self._file.write("\r\033[K")
845
+ self._console.print(f"[{char_style}]{char}[/{char_style}] {message}")
846
+
847
+ # Render remaining logs
848
+ for i in range(self._log_zone_height):
849
+ self._file.write("\033[K")
850
+ if i < len(logs):
851
+ self._console.print(f" {logs[i]}", end="")
852
+ self._file.write("\n")
853
+
854
+ self._file.flush()
855
+
856
+ def _hide_cursor(self) -> None:
857
+ """Hide cursor."""
858
+ self._file.write("\033[?25l")
859
+ self._file.flush()
860
+
861
+ def _show_cursor(self) -> None:
862
+ """Show cursor."""
863
+ self._file.write("\033[?25h")
864
+ self._file.flush()