kash-shell 0.3.20__py3-none-any.whl → 0.3.22__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.
- kash/actions/core/markdownify_html.py +11 -0
- kash/actions/core/tabbed_webpage_generate.py +2 -2
- kash/commands/help/assistant_commands.py +2 -4
- kash/commands/help/logo.py +12 -17
- kash/commands/help/welcome.py +5 -4
- kash/config/colors.py +8 -6
- kash/config/text_styles.py +2 -0
- kash/docs/markdown/topics/b1_kash_overview.md +34 -45
- kash/docs/markdown/warning.md +3 -3
- kash/docs/markdown/welcome.md +2 -1
- kash/exec/action_decorators.py +20 -5
- kash/exec/fetch_url_items.py +6 -4
- kash/exec/llm_transforms.py +1 -1
- kash/exec/preconditions.py +7 -2
- kash/exec/shell_callable_action.py +1 -1
- kash/llm_utils/llm_completion.py +1 -1
- kash/model/actions_model.py +6 -0
- kash/model/items_model.py +14 -11
- kash/shell/output/shell_output.py +20 -1
- kash/utils/api_utils/api_retries.py +305 -0
- kash/utils/api_utils/cache_requests_limited.py +84 -0
- kash/utils/api_utils/gather_limited.py +987 -0
- kash/utils/api_utils/progress_protocol.py +299 -0
- kash/utils/common/function_inspect.py +66 -1
- kash/utils/common/testing.py +10 -7
- kash/utils/rich_custom/multitask_status.py +631 -0
- kash/utils/text_handling/escape_html_tags.py +16 -11
- kash/utils/text_handling/markdown_render.py +1 -0
- kash/utils/text_handling/markdown_utils.py +158 -1
- kash/web_gen/tabbed_webpage.py +2 -2
- kash/web_gen/templates/base_styles.css.jinja +26 -20
- kash/web_gen/templates/components/toc_styles.css.jinja +1 -1
- kash/web_gen/templates/components/tooltip_scripts.js.jinja +171 -19
- kash/web_gen/templates/components/tooltip_styles.css.jinja +23 -8
- kash/xonsh_custom/load_into_xonsh.py +0 -3
- {kash_shell-0.3.20.dist-info → kash_shell-0.3.22.dist-info}/METADATA +3 -1
- {kash_shell-0.3.20.dist-info → kash_shell-0.3.22.dist-info}/RECORD +40 -35
- {kash_shell-0.3.20.dist-info → kash_shell-0.3.22.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.20.dist-info → kash_shell-0.3.22.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.20.dist-info → kash_shell-0.3.22.dist-info}/licenses/LICENSE +0 -0
|
@@ -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())
|