kash-shell 0.3.20__py3-none-any.whl → 0.3.21__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,631 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from contextlib import AbstractAsyncContextManager
5
+ from dataclasses import dataclass
6
+ from types import TracebackType
7
+ from typing import TYPE_CHECKING, TypeVar
8
+
9
+ from strif import abbrev_str, single_line
10
+ from typing_extensions import override
11
+
12
+ if TYPE_CHECKING:
13
+ from rich.progress import ProgressColumn
14
+
15
+ from rich.console import Console, RenderableType
16
+ from rich.progress import BarColumn, Progress, ProgressColumn, Task, TaskID
17
+ from rich.spinner import Spinner
18
+ from rich.text import Text
19
+
20
+ from kash.utils.api_utils.progress_protocol import (
21
+ EMOJI_FAILURE,
22
+ EMOJI_RETRY,
23
+ EMOJI_SKIP,
24
+ EMOJI_SUCCESS,
25
+ TaskInfo,
26
+ TaskState,
27
+ TaskSummary,
28
+ )
29
+
30
+ T = TypeVar("T")
31
+
32
+ # Spinner configuration
33
+ SPINNER_NAME = "dots12"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class StatusStyles:
38
+ """
39
+ All emojis and styles used in TaskStatus display.
40
+ Centralized for easy customization and consistency.
41
+ """
42
+
43
+ # Emoji symbols
44
+ success_symbol: str = EMOJI_SUCCESS
45
+ failure_symbol: str = EMOJI_FAILURE
46
+ skip_symbol: str = EMOJI_SKIP
47
+ retry_symbol: str = EMOJI_RETRY
48
+
49
+ # Status styles
50
+ retry_style: str = "red"
51
+ success_style: str = "green"
52
+ failure_style: str = "red"
53
+ skip_style: str = "yellow"
54
+ running_style: str = "blue"
55
+ error_style: str = "dim red"
56
+
57
+ # Progress bar styles
58
+ progress_complete_style: str = "green"
59
+
60
+
61
+ # Default styles instance
62
+ DEFAULT_STYLES = StatusStyles()
63
+
64
+ # Display symbols
65
+ RUNNING_SYMBOL = ""
66
+
67
+ # Layout constants
68
+ DEFAULT_LABEL_WIDTH = 40
69
+ DEFAULT_PROGRESS_WIDTH = 20
70
+
71
+
72
+ # Calculate spinner width to maintain column alignment
73
+ def _get_spinner_width(spinner_name: str) -> int:
74
+ """Calculate the maximum width of a spinner's frames."""
75
+ spinner = Spinner(spinner_name)
76
+ return max(len(frame) for frame in spinner.frames)
77
+
78
+
79
+ # Test message symbols
80
+ TEST_SUCCESS_PREFIX = EMOJI_SUCCESS
81
+ TEST_COMPLETION_MESSAGE = f"{EMOJI_SUCCESS} All operations completed successfully"
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class StatusSettings:
86
+ """
87
+ Configuration settings for TaskStatus display appearance and behavior.
88
+
89
+ Contains all display and styling options that control how the task status
90
+ interface appears and behaves, excluding runtime state like console and
91
+ final message.
92
+ """
93
+
94
+ show_progress: bool = False
95
+ progress_width: int = DEFAULT_PROGRESS_WIDTH
96
+ label_width: int = DEFAULT_LABEL_WIDTH
97
+ transient: bool = True
98
+ refresh_per_second: float = 10
99
+ styles: StatusStyles = DEFAULT_STYLES
100
+
101
+
102
+ class SpinnerStatusColumn(ProgressColumn):
103
+ """
104
+ Column showing spinner when running, status symbol when complete (same width).
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ *,
110
+ spinner_name: str = SPINNER_NAME,
111
+ styles: StatusStyles = DEFAULT_STYLES,
112
+ ):
113
+ super().__init__()
114
+ self.spinner: Spinner = Spinner(spinner_name)
115
+ self.styles = styles
116
+
117
+ # Calculate fixed width for consistent column sizing
118
+ self.column_width: int = max(
119
+ _get_spinner_width(spinner_name),
120
+ len(styles.success_symbol),
121
+ len(styles.failure_symbol),
122
+ len(styles.skip_symbol),
123
+ )
124
+
125
+ @override
126
+ def render(self, task: Task) -> Text:
127
+ """Render spinner when running, status symbol when complete."""
128
+ # Get task info from fields
129
+ task_info: TaskInfo | None = task.fields.get("task_info")
130
+ if not task_info or task_info.state == TaskState.QUEUED:
131
+ return Text(" " * self.column_width)
132
+
133
+ if task_info.state == TaskState.COMPLETED:
134
+ text = Text(self.styles.success_symbol, style=self.styles.success_style)
135
+ elif task_info.state == TaskState.FAILED:
136
+ text = Text(self.styles.failure_symbol, style=self.styles.failure_style)
137
+ elif task_info.state == TaskState.SKIPPED:
138
+ text = Text(self.styles.skip_symbol, style=self.styles.skip_style)
139
+ else:
140
+ # Running: show spinner
141
+ spinner_result = self.spinner.render(task.get_time())
142
+ if isinstance(spinner_result, Text):
143
+ text = spinner_result
144
+ else:
145
+ text = Text(str(spinner_result))
146
+
147
+ # Ensure consistent width
148
+ current_len = len(text.plain)
149
+ if current_len < self.column_width:
150
+ text.append(" " * (self.column_width - current_len))
151
+
152
+ return text
153
+
154
+
155
+ class ErrorIndicatorColumn(ProgressColumn):
156
+ """
157
+ Column showing retry indicators and error messages.
158
+ """
159
+
160
+ def __init__(
161
+ self,
162
+ *,
163
+ styles: StatusStyles = DEFAULT_STYLES,
164
+ min_error_length: int = 20,
165
+ ):
166
+ super().__init__()
167
+ self.styles = styles
168
+ self.min_error_length: int = min_error_length
169
+ self._current_max_length: int = min_error_length
170
+
171
+ @override
172
+ def render(self, task: Task) -> Text:
173
+ """Render retry indicators and last error message."""
174
+ # Get task info from fields
175
+ task_info: TaskInfo | None = task.fields.get("task_info")
176
+ if not task_info or task_info.retry_count == 0:
177
+ return Text("")
178
+
179
+ text = Text()
180
+
181
+ # Add retry indicators (red dots for each failure)
182
+ retry_text = self.styles.retry_symbol * task_info.retry_count
183
+ text.append(retry_text, style=self.styles.retry_style)
184
+
185
+ # Add last error message if available
186
+ if task_info.failures:
187
+ text.append(" ")
188
+ last_error = task_info.failures[-1]
189
+
190
+ # Ensure single line and truncate to max length
191
+ text.append(
192
+ abbrev_str(single_line(last_error), max_len=self._current_max_length),
193
+ style=self.styles.error_style,
194
+ )
195
+
196
+ return text
197
+
198
+
199
+ class CustomProgressColumn(ProgressColumn):
200
+ """
201
+ Column that renders arbitrary Rich elements from task fields.
202
+ """
203
+
204
+ def __init__(self, field_name: str = "progress_display"):
205
+ super().__init__()
206
+ self.field_name: str = field_name
207
+
208
+ @override
209
+ def render(self, task: Task) -> RenderableType:
210
+ """Render custom progress element from task fields."""
211
+ progress_display = task.fields.get(self.field_name)
212
+ return progress_display if progress_display is not None else ""
213
+
214
+
215
+ class TruncatedLabelColumn(ProgressColumn):
216
+ """
217
+ Column that shows task labels truncated to half console width.
218
+ """
219
+
220
+ def __init__(self, console_width: int):
221
+ super().__init__()
222
+ # Reserve half the console width for labels/status messages
223
+ self.max_label_width = console_width // 2
224
+
225
+ @override
226
+ def render(self, task: Task) -> Text:
227
+ """Render task label truncated to max width."""
228
+ label = task.fields.get("label", "")
229
+ if isinstance(label, str):
230
+ truncated_label = abbrev_str(single_line(label), max_len=self.max_label_width)
231
+ return Text(truncated_label)
232
+ return Text(str(label))
233
+
234
+
235
+ class MultiTaskStatus(AbstractAsyncContextManager):
236
+ """
237
+ Context manager for live progress status reporting of multiple tasks, a bit like
238
+ uv or pnpm status output when installing packages.
239
+
240
+ Layout: [Spinner/Status] [Label] [Progress] [Error indicators + message]
241
+
242
+ Features:
243
+ - Fixed-width labels on the left
244
+ - Optional custom progress display (progress bar, percentage, text, etc.)
245
+ - Retry indicators (dots) and status symbols on the right
246
+ - Spinners for active tasks
247
+ - Option to clear display and show final message when done
248
+
249
+ Example:
250
+ ```python
251
+ async with TaskStatus(
252
+ show_progress=True,
253
+ transient=True,
254
+ final_message=f"{SUCCESS_SYMBOL} All operations completed"
255
+ ) as status:
256
+ # Standard progress bar
257
+ task1 = await status.add("Downloading", total=100)
258
+
259
+ # Custom percentage display
260
+ task2 = await status.add("Processing")
261
+ await status.set_progress_display(task2, "45%")
262
+
263
+ # Custom text
264
+ task3 = await status.add("Analyzing")
265
+ await status.set_progress_display(task3, Text("checking...", style="yellow"))
266
+ ```
267
+ """
268
+
269
+ def __init__(
270
+ self,
271
+ *,
272
+ console: Console | None = None,
273
+ settings: StatusSettings | None = None,
274
+ auto_summary: bool = True,
275
+ ):
276
+ """
277
+ Initialize TaskStatus display.
278
+
279
+ Args:
280
+ console: Rich Console instance, or None for default
281
+ settings: Display configuration settings
282
+ auto_summary: Generate automatic summary message when exiting (if transient=True)
283
+ """
284
+ self.console: Console = console or Console()
285
+ self.settings: StatusSettings = settings or StatusSettings()
286
+ self.auto_summary: bool = auto_summary
287
+ self._lock: asyncio.Lock = asyncio.Lock()
288
+ self._task_info: dict[int, TaskInfo] = {}
289
+ self._next_id: int = 1
290
+ self._rich_task_ids: dict[int, TaskID] = {} # Map our IDs to Rich Progress IDs
291
+
292
+ # Calculate spinner width for consistent spacing
293
+ self._spinner_width = _get_spinner_width(SPINNER_NAME)
294
+
295
+ # Create columns
296
+ spinner_status_column = SpinnerStatusColumn(
297
+ spinner_name=SPINNER_NAME,
298
+ styles=self.settings.styles,
299
+ )
300
+
301
+ error_column = ErrorIndicatorColumn(
302
+ styles=self.settings.styles,
303
+ )
304
+
305
+ label_column = TruncatedLabelColumn(console_width=self.console.size.width)
306
+
307
+ # Store references to columns so we can update them with console info
308
+ self._error_column = error_column
309
+ self._label_column = label_column
310
+
311
+ # Build column layout: Spinner/Status | Label | [Progress] | Error indicators
312
+ columns: list[ProgressColumn] = [
313
+ spinner_status_column,
314
+ label_column,
315
+ ]
316
+
317
+ # Add optional progress column
318
+ if self.settings.show_progress:
319
+ # Add a standard progress bar column AND custom display column
320
+ columns.append(
321
+ BarColumn(
322
+ bar_width=self.settings.progress_width,
323
+ complete_style=self.settings.styles.progress_complete_style,
324
+ finished_style=self.settings.styles.progress_complete_style,
325
+ )
326
+ )
327
+ columns.append(CustomProgressColumn("progress_display"))
328
+
329
+ # Add error indicators (retry dots + error messages)
330
+ columns.append(error_column)
331
+
332
+ self._progress: Progress = Progress(
333
+ *columns,
334
+ console=self.console,
335
+ transient=self.settings.transient,
336
+ refresh_per_second=self.settings.refresh_per_second,
337
+ )
338
+
339
+ # Now that we have console access, update columns with proper max lengths
340
+ self._update_column_widths()
341
+
342
+ def _update_column_widths(self) -> None:
343
+ """Update column widths based on console width - half for labels, half for errors."""
344
+ console_width = self.console.size.width
345
+
346
+ self._error_column._current_max_length = max(
347
+ self._error_column.min_error_length, console_width // 2
348
+ )
349
+
350
+ # Update label column max width (half console width)
351
+ self._label_column.max_label_width = console_width // 2
352
+
353
+ @property
354
+ def suppress_logs(self) -> bool:
355
+ """Rich-based tracker manages its own display and suppresses standard logging."""
356
+ return True
357
+
358
+ @override
359
+ async def __aenter__(self) -> MultiTaskStatus:
360
+ """Start the live display."""
361
+ self._progress.__enter__()
362
+ return self
363
+
364
+ @override
365
+ async def __aexit__(
366
+ self,
367
+ exc_type: type[BaseException] | None,
368
+ exc_val: BaseException | None,
369
+ exc_tb: TracebackType | None,
370
+ ) -> None:
371
+ """Stop the live display and show automatic summary if enabled."""
372
+ self._progress.__exit__(exc_type, exc_val, exc_tb)
373
+
374
+ # Show automatic summary if enabled
375
+ if self.auto_summary:
376
+ summary = self.get_summary()
377
+ self.console.print(summary)
378
+
379
+ async def add(self, label: str, total: int | None = None) -> int:
380
+ """
381
+ Add a new task to the display. Task won't appear until start() is called.
382
+
383
+ Args:
384
+ label: Human-readable task description
385
+ total: Total steps for progress bar (None for no default bar)
386
+
387
+ Returns:
388
+ Task ID for subsequent updates
389
+ """
390
+ async with self._lock:
391
+ # Generate our own task ID: don't add to Rich Progress yet
392
+ task_id: int = self._next_id
393
+ self._next_id += 1
394
+
395
+ task_info = TaskInfo(label=label, total=total or 1)
396
+ self._task_info[task_id] = task_info
397
+ return task_id
398
+
399
+ async def start(self, task_id: int) -> None:
400
+ """
401
+ Mark task as started (after rate limiting/queuing) and add to Rich display.
402
+
403
+ Args:
404
+ task_id: Task ID from add()
405
+ """
406
+ async with self._lock:
407
+ if task_id not in self._task_info:
408
+ return
409
+
410
+ task_info = self._task_info[task_id]
411
+ task_info.state = TaskState.RUNNING
412
+
413
+ # Now add to Rich Progress display
414
+ rich_task_id = self._progress.add_task(
415
+ "",
416
+ total=task_info.total,
417
+ label=task_info.label,
418
+ task_info=task_info,
419
+ progress_display=None,
420
+ )
421
+ self._rich_task_ids[task_id] = rich_task_id
422
+
423
+ async def set_progress_display(self, task_id: int, display: RenderableType) -> None:
424
+ """
425
+ Set custom progress display (percentage, text, etc.).
426
+
427
+ Args:
428
+ task_id: Task ID from add()
429
+ display: Any Rich renderable (str, Text, percentage, etc.)
430
+ """
431
+ if not self.settings.show_progress:
432
+ return
433
+
434
+ async with self._lock:
435
+ # Only update if task has been started (added to Rich Progress)
436
+ rich_task_id = self._rich_task_ids.get(task_id)
437
+ if rich_task_id is not None:
438
+ self._progress.update(rich_task_id, progress_display=display)
439
+
440
+ async def update(
441
+ self,
442
+ task_id: int,
443
+ *,
444
+ progress: int | None = None,
445
+ label: str | None = None,
446
+ error_msg: str | None = None,
447
+ ) -> None:
448
+ """
449
+ Update task progress, label, or record a retry attempt.
450
+
451
+ Args:
452
+ task_id: Task ID from add()
453
+ progress: Steps to advance (None = no change)
454
+ label: New label (None = no change)
455
+ error_msg: Error message to record as retry (None = no retry)
456
+ """
457
+ async with self._lock:
458
+ if task_id not in self._task_info:
459
+ return
460
+
461
+ task_info = self._task_info[task_id]
462
+ rich_task_id = self._rich_task_ids.get(task_id)
463
+
464
+ # Update label if provided
465
+ if label is not None:
466
+ task_info.label = label
467
+ if rich_task_id is not None:
468
+ self._progress.update(rich_task_id, label=label, task_info=task_info)
469
+
470
+ # Advance progress if provided
471
+ if progress is not None and rich_task_id is not None:
472
+ self._progress.advance(rich_task_id, advance=progress)
473
+
474
+ # Record retry if error message provided
475
+ if error_msg is not None:
476
+ task_info.retry_count += 1
477
+ task_info.failures.append(error_msg)
478
+ if rich_task_id is not None:
479
+ self._progress.update(rich_task_id, task_info=task_info)
480
+
481
+ async def finish(
482
+ self,
483
+ task_id: int,
484
+ state: TaskState,
485
+ message: str = "",
486
+ ) -> None:
487
+ """
488
+ Mark task as finished with final state.
489
+
490
+ Args:
491
+ task_id: Task ID from add()
492
+ state: Final state (COMPLETED, FAILED, SKIPPED)
493
+ message: Optional completion/error/skip message
494
+ """
495
+ async with self._lock:
496
+ if task_id not in self._task_info:
497
+ return
498
+
499
+ task_info = self._task_info[task_id]
500
+ task_info.state = state
501
+ rich_task_id = self._rich_task_ids.get(task_id)
502
+
503
+ if message:
504
+ task_info.failures.append(message)
505
+
506
+ # Complete the progress bar and stop spinner
507
+ if rich_task_id is not None:
508
+ total = self._progress.tasks[rich_task_id].total or 1
509
+ self._progress.update(rich_task_id, completed=total, task_info=task_info)
510
+ else:
511
+ # Task was never started, but we still need to add it to show completion
512
+ rich_task_id = self._progress.add_task(
513
+ "",
514
+ total=task_info.total,
515
+ label=task_info.label,
516
+ completed=task_info.total,
517
+ task_info=task_info,
518
+ )
519
+ self._rich_task_ids[task_id] = rich_task_id
520
+
521
+ def get_task_info(self, task_id: int) -> TaskInfo | None:
522
+ """Get additional task information."""
523
+ return self._task_info.get(task_id)
524
+
525
+ def get_task_states(self) -> list[TaskState]:
526
+ """Get list of all task states for custom summary generation."""
527
+ return [info.state for info in self._task_info.values()]
528
+
529
+ def get_summary(self) -> str:
530
+ """Generate summary message based on current task states."""
531
+ summary = TaskSummary(task_states=self.get_task_states())
532
+ return f"Tasks done: {summary.summary_str()}"
533
+
534
+ @property
535
+ def console_for_output(self) -> Console:
536
+ """Get console instance for additional output above progress."""
537
+ return self._progress.console
538
+
539
+
540
+ ## Tests
541
+
542
+
543
+ def test_task_status_basic():
544
+ """Test basic TaskStatus functionality."""
545
+ print("Testing TaskStatus...")
546
+
547
+ async def _test_impl():
548
+ async with MultiTaskStatus(
549
+ settings=StatusSettings(show_progress=False),
550
+ ) as status:
551
+ # Simple task without progress
552
+ task1 = await status.add("Simple task")
553
+ await asyncio.sleep(0.5)
554
+ await status.finish(task1, TaskState.COMPLETED)
555
+
556
+ # Task with retries
557
+ retry_task = await status.add("Task with retries")
558
+ await status.update(retry_task, error_msg="Connection timeout")
559
+ await asyncio.sleep(0.5)
560
+ await status.update(retry_task, error_msg="Server error")
561
+ await asyncio.sleep(0.5)
562
+ await status.finish(retry_task, TaskState.COMPLETED)
563
+
564
+ asyncio.run(_test_impl())
565
+
566
+
567
+ def test_task_status_with_progress():
568
+ """Test TaskStatus with different progress displays."""
569
+ print("Testing TaskStatus with progress...")
570
+
571
+ async def _test_impl():
572
+ async with MultiTaskStatus(
573
+ settings=StatusSettings(show_progress=True),
574
+ ) as status:
575
+ # Traditional progress bar
576
+ download_task = await status.add("Downloading", total=100)
577
+ for i in range(0, 101, 10):
578
+ await status.update(download_task, progress=10)
579
+ await asyncio.sleep(0.1)
580
+ await status.finish(download_task, TaskState.COMPLETED)
581
+
582
+ # Custom percentage display
583
+ process_task = await status.add("Processing")
584
+ for i in range(0, 101, 25):
585
+ await status.set_progress_display(process_task, f"{i}%")
586
+ await asyncio.sleep(0.2)
587
+ await status.finish(process_task, TaskState.COMPLETED)
588
+
589
+ # Custom text display
590
+ analyze_task = await status.add("Analyzing")
591
+ await status.set_progress_display(
592
+ analyze_task, Text("scanning files...", style="yellow")
593
+ )
594
+ await asyncio.sleep(0.5)
595
+ await status.set_progress_display(analyze_task, Text("building index...", style="cyan"))
596
+ await asyncio.sleep(0.5)
597
+ await status.finish(analyze_task, TaskState.COMPLETED)
598
+
599
+ asyncio.run(_test_impl())
600
+
601
+
602
+ def test_task_status_mixed():
603
+ """Test mixed scenarios including skip functionality."""
604
+ print("Testing TaskStatus mixed scenarios...")
605
+
606
+ async def _test_impl():
607
+ async with MultiTaskStatus(
608
+ settings=StatusSettings(show_progress=True, transient=True),
609
+ ) as status:
610
+ # Multiple concurrent tasks
611
+ install_task = await status.add("Installing packages", total=50)
612
+ test_task = await status.add("Running tests")
613
+ build_task = await status.add("Building project")
614
+ optional_task = await status.add("Optional feature")
615
+
616
+ # Simulate concurrent work
617
+ for i in range(5):
618
+ await status.update(install_task, progress=10)
619
+ await status.set_progress_display(test_task, f"Test {i + 1}/10")
620
+ await status.set_progress_display(build_task, Text(f"Step {i + 1}", style="blue"))
621
+ await asyncio.sleep(0.2)
622
+
623
+ await status.finish(install_task, TaskState.COMPLETED)
624
+ await status.update(test_task, error_msg="RateLimitError: Too many requests")
625
+ await status.finish(test_task, TaskState.COMPLETED)
626
+ await status.finish(build_task, TaskState.COMPLETED)
627
+
628
+ # Skip the fourth task to demonstrate skip functionality
629
+ await status.finish(optional_task, TaskState.SKIPPED, "Feature disabled in config")
630
+
631
+ asyncio.run(_test_impl())