kash-shell 0.3.18__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.
- kash/actions/core/{markdownify.py → markdownify_html.py} +3 -6
- kash/commands/workspace/workspace_commands.py +10 -88
- kash/config/colors.py +8 -6
- kash/config/text_styles.py +2 -0
- kash/docs/markdown/topics/a1_what_is_kash.md +1 -1
- kash/docs/markdown/topics/b1_kash_overview.md +34 -45
- kash/exec/__init__.py +3 -0
- kash/exec/action_decorators.py +20 -5
- kash/exec/action_exec.py +2 -2
- kash/exec/{fetch_url_metadata.py → fetch_url_items.py} +42 -14
- kash/exec/llm_transforms.py +1 -1
- kash/exec/shell_callable_action.py +1 -1
- kash/file_storage/file_store.py +7 -1
- kash/file_storage/store_filenames.py +4 -0
- kash/help/function_param_info.py +1 -1
- kash/help/help_pages.py +1 -1
- kash/help/help_printing.py +1 -1
- kash/llm_utils/llm_completion.py +1 -1
- kash/model/actions_model.py +6 -0
- kash/model/items_model.py +18 -3
- kash/shell/output/shell_output.py +15 -0
- 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/parse_docstring.py +347 -0
- 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/web_content/web_extract.py +34 -15
- kash/web_content/web_page_model.py +10 -1
- 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_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/METADATA +4 -2
- {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/RECORD +42 -37
- kash/help/docstring_utils.py +0 -111
- {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, Protocol, TypeAlias, TypeVar
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
TaskID = TypeVar("TaskID")
|
|
10
|
+
|
|
11
|
+
# Generic task spec types for labeler functions
|
|
12
|
+
TaskSpec = TypeVar("TaskSpec")
|
|
13
|
+
Labeler: TypeAlias = Callable[[int, TaskSpec], str]
|
|
14
|
+
|
|
15
|
+
# Progress display symbols (consistent with text_styles.py)
|
|
16
|
+
EMOJI_SUCCESS = "[✔︎]"
|
|
17
|
+
EMOJI_FAILURE = "[✘]"
|
|
18
|
+
EMOJI_SKIP = "[-]"
|
|
19
|
+
EMOJI_WARN = "[∆]"
|
|
20
|
+
EMOJI_RETRY = "▵"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TaskState(Enum):
|
|
24
|
+
"""Task execution states."""
|
|
25
|
+
|
|
26
|
+
QUEUED = "queued"
|
|
27
|
+
RUNNING = "running"
|
|
28
|
+
COMPLETED = "completed"
|
|
29
|
+
FAILED = "failed"
|
|
30
|
+
SKIPPED = "skipped"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class TaskInfo:
|
|
35
|
+
"""Track additional task information beyond basic progress."""
|
|
36
|
+
|
|
37
|
+
state: TaskState = TaskState.QUEUED
|
|
38
|
+
retry_count: int = 0
|
|
39
|
+
failures: list[str] = field(default_factory=list)
|
|
40
|
+
label: str = ""
|
|
41
|
+
total: int = 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class TaskSummary:
|
|
46
|
+
"""Summary of task completion states."""
|
|
47
|
+
|
|
48
|
+
task_states: list[TaskState]
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def queued(self) -> int:
|
|
52
|
+
"""Number of queued tasks."""
|
|
53
|
+
return sum(1 for state in self.task_states if state == TaskState.QUEUED)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def running(self) -> int:
|
|
57
|
+
"""Number of running tasks."""
|
|
58
|
+
return sum(1 for state in self.task_states if state == TaskState.RUNNING)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def completed(self) -> int:
|
|
62
|
+
"""Number of completed tasks."""
|
|
63
|
+
return sum(1 for state in self.task_states if state == TaskState.COMPLETED)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def failed(self) -> int:
|
|
67
|
+
"""Number of failed tasks."""
|
|
68
|
+
return sum(1 for state in self.task_states if state == TaskState.FAILED)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def skipped(self) -> int:
|
|
72
|
+
"""Number of skipped tasks."""
|
|
73
|
+
return sum(1 for state in self.task_states if state == TaskState.SKIPPED)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def total(self) -> int:
|
|
77
|
+
"""Total number of tasks."""
|
|
78
|
+
return len(self.task_states)
|
|
79
|
+
|
|
80
|
+
def summary_str(self) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Generate summary message based on task completion states.
|
|
83
|
+
"""
|
|
84
|
+
if not self.task_states:
|
|
85
|
+
return "No tasks to process"
|
|
86
|
+
|
|
87
|
+
if self.completed == self.total:
|
|
88
|
+
return f"All tasks successful: {self.completed}/{self.total} completed"
|
|
89
|
+
elif self.completed + self.skipped == self.total:
|
|
90
|
+
return f"All tasks successful: {self.completed}/{self.total} completed, {self.skipped} skipped"
|
|
91
|
+
elif self.failed == self.total:
|
|
92
|
+
return f"All tasks failed: {self.failed}/{self.total} failed"
|
|
93
|
+
else:
|
|
94
|
+
parts = []
|
|
95
|
+
if self.completed > 0:
|
|
96
|
+
parts.append(f"{self.completed}/{self.total} tasks completed")
|
|
97
|
+
if self.failed > 0:
|
|
98
|
+
parts.append(f"{self.failed} tasks failed")
|
|
99
|
+
if self.skipped > 0:
|
|
100
|
+
parts.append(f"{self.skipped} tasks skipped")
|
|
101
|
+
if self.queued > 0:
|
|
102
|
+
parts.append(f"{self.queued} tasks not yet run")
|
|
103
|
+
|
|
104
|
+
if self.queued > 0:
|
|
105
|
+
return "Tasks were interrupted: " + ", ".join(parts)
|
|
106
|
+
else:
|
|
107
|
+
return "Tasks had errors: " + ", ".join(parts)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ProgressTracker(Protocol[TaskID]):
|
|
111
|
+
"""
|
|
112
|
+
Protocol for progress tracking that gather_limited can depend on.
|
|
113
|
+
|
|
114
|
+
This allows different implementations (Rich, simple logging, etc.)
|
|
115
|
+
without creating a hard dependency. Uses a simplified update model.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def suppress_logs(self) -> bool:
|
|
120
|
+
"""
|
|
121
|
+
Whether this tracker handles its own display and should suppress
|
|
122
|
+
standard logging to avoid visual interference.
|
|
123
|
+
"""
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
async def add(self, label: str, total: int = 1) -> TaskID:
|
|
127
|
+
"""Add a new task to track."""
|
|
128
|
+
...
|
|
129
|
+
|
|
130
|
+
async def start(self, task_id: TaskID) -> None:
|
|
131
|
+
"""Mark task as started (after rate limiting/queuing)."""
|
|
132
|
+
...
|
|
133
|
+
|
|
134
|
+
async def update(
|
|
135
|
+
self,
|
|
136
|
+
task_id: TaskID,
|
|
137
|
+
*,
|
|
138
|
+
progress: int | None = None,
|
|
139
|
+
label: str | None = None,
|
|
140
|
+
error_msg: str | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Update task progress, label, or record a retry attempt.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
task_id: Task ID from add()
|
|
147
|
+
progress: Steps to advance (None = no change)
|
|
148
|
+
label: New label (None = no change)
|
|
149
|
+
error_msg: Error message to record as retry (None = no retry)
|
|
150
|
+
"""
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
async def finish(
|
|
154
|
+
self,
|
|
155
|
+
task_id: TaskID,
|
|
156
|
+
state: TaskState,
|
|
157
|
+
message: str = "",
|
|
158
|
+
) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Mark task as finished with final state.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
task_id: Task ID from add()
|
|
164
|
+
state: Final state (COMPLETED, FAILED, SKIPPED)
|
|
165
|
+
message: Optional completion/error/skip message
|
|
166
|
+
"""
|
|
167
|
+
...
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class AsyncProgressContext(Protocol[TaskID]):
|
|
171
|
+
"""Protocol for async context manager progress trackers."""
|
|
172
|
+
|
|
173
|
+
async def __aenter__(self) -> ProgressTracker[TaskID]:
|
|
174
|
+
"""Start progress tracking."""
|
|
175
|
+
...
|
|
176
|
+
|
|
177
|
+
async def __aexit__(
|
|
178
|
+
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Stop progress tracking."""
|
|
181
|
+
...
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class SimpleProgressTracker:
|
|
185
|
+
"""
|
|
186
|
+
Basic progress tracker that logs to console.
|
|
187
|
+
Useful for testing or when Rich is not available.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def __init__(self, *, verbose: bool = True, print_fn: Callable[[str], None] = print):
|
|
191
|
+
self.verbose = verbose
|
|
192
|
+
self.print_fn = print_fn
|
|
193
|
+
self._next_id = 1
|
|
194
|
+
self._tasks: dict[int, TaskInfo] = {}
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def suppress_logs(self) -> bool:
|
|
198
|
+
"""Console-based tracker works with standard logging."""
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
async def add(self, label: str, total: int = 1) -> int: # pyright: ignore[reportUnusedParameter]
|
|
202
|
+
task_id = self._next_id
|
|
203
|
+
self._next_id += 1
|
|
204
|
+
|
|
205
|
+
self._tasks[task_id] = TaskInfo(label=label)
|
|
206
|
+
|
|
207
|
+
if self.verbose:
|
|
208
|
+
self.print_fn(f"Queued: {label}")
|
|
209
|
+
|
|
210
|
+
return task_id
|
|
211
|
+
|
|
212
|
+
async def start(self, task_id: int) -> None:
|
|
213
|
+
"""Mark task as started (after rate limiting/queuing)."""
|
|
214
|
+
task_info = self._tasks.get(task_id)
|
|
215
|
+
if not task_info:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
task_info.state = TaskState.RUNNING
|
|
219
|
+
|
|
220
|
+
if self.verbose:
|
|
221
|
+
self.print_fn(f"Started: {task_info.label}")
|
|
222
|
+
|
|
223
|
+
async def update(
|
|
224
|
+
self,
|
|
225
|
+
task_id: int,
|
|
226
|
+
*,
|
|
227
|
+
progress: int | None = None, # pyright: ignore[reportUnusedParameter]
|
|
228
|
+
label: str | None = None,
|
|
229
|
+
error_msg: str | None = None,
|
|
230
|
+
) -> None:
|
|
231
|
+
task_info = self._tasks.get(task_id)
|
|
232
|
+
if not task_info:
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
# Update label if provided
|
|
236
|
+
if label is not None:
|
|
237
|
+
task_info.label = label
|
|
238
|
+
|
|
239
|
+
# Record retry if error message provided
|
|
240
|
+
if error_msg is not None:
|
|
241
|
+
task_info.retry_count += 1
|
|
242
|
+
task_info.failures.append(error_msg)
|
|
243
|
+
|
|
244
|
+
if self.verbose:
|
|
245
|
+
retry_indicator = EMOJI_RETRY * task_info.retry_count
|
|
246
|
+
self.print_fn(f"Retry {retry_indicator} {task_info.label}: {error_msg}")
|
|
247
|
+
|
|
248
|
+
async def finish(
|
|
249
|
+
self,
|
|
250
|
+
task_id: int,
|
|
251
|
+
state: TaskState,
|
|
252
|
+
message: str = "",
|
|
253
|
+
) -> None:
|
|
254
|
+
task_info = self._tasks.get(task_id)
|
|
255
|
+
if not task_info:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
task_info.state = state
|
|
259
|
+
|
|
260
|
+
if self.verbose:
|
|
261
|
+
if state == TaskState.COMPLETED:
|
|
262
|
+
symbol = EMOJI_SUCCESS
|
|
263
|
+
elif state == TaskState.FAILED:
|
|
264
|
+
symbol = EMOJI_FAILURE
|
|
265
|
+
elif state == TaskState.SKIPPED:
|
|
266
|
+
symbol = EMOJI_SKIP
|
|
267
|
+
else:
|
|
268
|
+
symbol = "?"
|
|
269
|
+
|
|
270
|
+
retry_info = (
|
|
271
|
+
f" (after {task_info.retry_count} retries)" if task_info.retry_count else ""
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
message_info = f": {message}" if message else ""
|
|
275
|
+
self.print_fn(f"{symbol} {task_info.label}{retry_info}{message_info}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class SimpleProgressContext:
|
|
279
|
+
"""
|
|
280
|
+
Simple async context manager for SimpleProgressTracker.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
def __init__(self, *, verbose: bool = True, print_fn: Callable[[str], None] = print):
|
|
284
|
+
self.verbose = verbose
|
|
285
|
+
self.print_fn = print_fn
|
|
286
|
+
self._tracker: SimpleProgressTracker | None = None
|
|
287
|
+
|
|
288
|
+
async def __aenter__(self) -> SimpleProgressTracker:
|
|
289
|
+
self._tracker = SimpleProgressTracker(verbose=self.verbose, print_fn=self.print_fn)
|
|
290
|
+
return self._tracker
|
|
291
|
+
|
|
292
|
+
async def __aexit__(
|
|
293
|
+
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any
|
|
294
|
+
) -> None:
|
|
295
|
+
if self.verbose and self._tracker:
|
|
296
|
+
# Generate automatic summary
|
|
297
|
+
task_states = [info.state for info in self._tracker._tasks.values()]
|
|
298
|
+
summary = TaskSummary(task_states=task_states)
|
|
299
|
+
self.print_fn(summary.summary_str())
|
|
@@ -11,6 +11,7 @@ from typing import (
|
|
|
11
11
|
cast,
|
|
12
12
|
get_args,
|
|
13
13
|
get_origin,
|
|
14
|
+
get_type_hints,
|
|
14
15
|
)
|
|
15
16
|
|
|
16
17
|
NO_DEFAULT = Parameter.empty # Alias for clarity
|
|
@@ -150,10 +151,19 @@ def inspect_function_params(func: Callable[..., Any], unwrap: bool = True) -> li
|
|
|
150
151
|
unwrapped_func = inspect.unwrap(func) if unwrap else func
|
|
151
152
|
signature = inspect.signature(unwrapped_func)
|
|
152
153
|
|
|
154
|
+
# Get resolved type hints to handle string annotations from __future__ annotations
|
|
155
|
+
try:
|
|
156
|
+
type_hints = get_type_hints(unwrapped_func)
|
|
157
|
+
except (NameError, AttributeError, TypeError):
|
|
158
|
+
# If we can't resolve type hints (missing imports, etc.), fall back to raw annotations
|
|
159
|
+
type_hints = {}
|
|
160
|
+
|
|
153
161
|
param_infos: list[FuncParam] = []
|
|
154
162
|
|
|
155
163
|
for i, (param_name, param_obj) in enumerate(signature.parameters.items()):
|
|
156
|
-
|
|
164
|
+
# Use resolved type hint if available, otherwise fall back to raw annotation
|
|
165
|
+
resolved_annotation = type_hints.get(param_name, param_obj.annotation)
|
|
166
|
+
effective_type, inner_type, is_optional = _resolve_type_details(resolved_annotation)
|
|
157
167
|
|
|
158
168
|
# Fallback: if type is not resolved from annotation, try to infer from default value.
|
|
159
169
|
if (
|
|
@@ -522,3 +532,58 @@ def test_literal_types():
|
|
|
522
532
|
assert param.name == "flag"
|
|
523
533
|
assert param.effective_type is bool
|
|
524
534
|
assert param.default is True
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def test_string_annotations():
|
|
538
|
+
"""Test string annotations (from __future__ import annotations) are properly resolved."""
|
|
539
|
+
|
|
540
|
+
class Item: # pyright: ignore[reportUnusedClass]
|
|
541
|
+
pass
|
|
542
|
+
|
|
543
|
+
# Create a function with string annotations to simulate the __future__ annotations behavior
|
|
544
|
+
def func_with_string_annotations(item):
|
|
545
|
+
return item
|
|
546
|
+
|
|
547
|
+
# Manually set the annotation to a string to simulate __future__ annotations
|
|
548
|
+
func_with_string_annotations.__annotations__ = {"item": "Item"}
|
|
549
|
+
|
|
550
|
+
# This should NOT raise an error and should resolve the type properly
|
|
551
|
+
params = inspect_function_params(func_with_string_annotations)
|
|
552
|
+
assert len(params) == 1
|
|
553
|
+
param = params[0]
|
|
554
|
+
assert param.name == "item"
|
|
555
|
+
# The annotation should be preserved as a string
|
|
556
|
+
assert param.annotation == "Item"
|
|
557
|
+
# effective_type might be None if "Item" can't be resolved due to scope issues
|
|
558
|
+
# but the important thing is that it doesn't crash
|
|
559
|
+
# Note: get_type_hints() can't resolve local classes defined in test functions
|
|
560
|
+
|
|
561
|
+
# Test with a known built-in type as a string
|
|
562
|
+
def func_with_builtin_string_annotation(value):
|
|
563
|
+
return str(value)
|
|
564
|
+
|
|
565
|
+
# Manually set string annotations
|
|
566
|
+
func_with_builtin_string_annotation.__annotations__ = {"value": "int"}
|
|
567
|
+
|
|
568
|
+
params = inspect_function_params(func_with_builtin_string_annotation)
|
|
569
|
+
assert len(params) == 1
|
|
570
|
+
param = params[0]
|
|
571
|
+
assert param.name == "value"
|
|
572
|
+
assert param.annotation == "int"
|
|
573
|
+
# This should resolve to the actual int type
|
|
574
|
+
assert param.effective_type is int
|
|
575
|
+
|
|
576
|
+
# Test with a complex type annotation as string
|
|
577
|
+
def func_with_complex_string_annotation(items):
|
|
578
|
+
return items
|
|
579
|
+
|
|
580
|
+
func_with_complex_string_annotation.__annotations__ = {"items": "list[str]"}
|
|
581
|
+
|
|
582
|
+
params = inspect_function_params(func_with_complex_string_annotation)
|
|
583
|
+
assert len(params) == 1
|
|
584
|
+
param = params[0]
|
|
585
|
+
assert param.name == "items"
|
|
586
|
+
assert param.annotation == "list[str]"
|
|
587
|
+
# This should resolve to list with inner type str
|
|
588
|
+
assert param.effective_type is list
|
|
589
|
+
assert param.inner_type is str
|