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.
Files changed (40) hide show
  1. kash/actions/core/markdownify_html.py +11 -0
  2. kash/actions/core/tabbed_webpage_generate.py +2 -2
  3. kash/commands/help/assistant_commands.py +2 -4
  4. kash/commands/help/logo.py +12 -17
  5. kash/commands/help/welcome.py +5 -4
  6. kash/config/colors.py +8 -6
  7. kash/config/text_styles.py +2 -0
  8. kash/docs/markdown/topics/b1_kash_overview.md +34 -45
  9. kash/docs/markdown/warning.md +3 -3
  10. kash/docs/markdown/welcome.md +2 -1
  11. kash/exec/action_decorators.py +20 -5
  12. kash/exec/fetch_url_items.py +6 -4
  13. kash/exec/llm_transforms.py +1 -1
  14. kash/exec/preconditions.py +7 -2
  15. kash/exec/shell_callable_action.py +1 -1
  16. kash/llm_utils/llm_completion.py +1 -1
  17. kash/model/actions_model.py +6 -0
  18. kash/model/items_model.py +14 -11
  19. kash/shell/output/shell_output.py +20 -1
  20. kash/utils/api_utils/api_retries.py +305 -0
  21. kash/utils/api_utils/cache_requests_limited.py +84 -0
  22. kash/utils/api_utils/gather_limited.py +987 -0
  23. kash/utils/api_utils/progress_protocol.py +299 -0
  24. kash/utils/common/function_inspect.py +66 -1
  25. kash/utils/common/testing.py +10 -7
  26. kash/utils/rich_custom/multitask_status.py +631 -0
  27. kash/utils/text_handling/escape_html_tags.py +16 -11
  28. kash/utils/text_handling/markdown_render.py +1 -0
  29. kash/utils/text_handling/markdown_utils.py +158 -1
  30. kash/web_gen/tabbed_webpage.py +2 -2
  31. kash/web_gen/templates/base_styles.css.jinja +26 -20
  32. kash/web_gen/templates/components/toc_styles.css.jinja +1 -1
  33. kash/web_gen/templates/components/tooltip_scripts.js.jinja +171 -19
  34. kash/web_gen/templates/components/tooltip_styles.css.jinja +23 -8
  35. kash/xonsh_custom/load_into_xonsh.py +0 -3
  36. {kash_shell-0.3.20.dist-info → kash_shell-0.3.22.dist-info}/METADATA +3 -1
  37. {kash_shell-0.3.20.dist-info → kash_shell-0.3.22.dist-info}/RECORD +40 -35
  38. {kash_shell-0.3.20.dist-info → kash_shell-0.3.22.dist-info}/WHEEL +0 -0
  39. {kash_shell-0.3.20.dist-info → kash_shell-0.3.22.dist-info}/entry_points.txt +0 -0
  40. {kash_shell-0.3.20.dist-info → kash_shell-0.3.22.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
- effective_type, inner_type, is_optional = _resolve_type_details(param_obj.annotation)
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
@@ -3,16 +3,19 @@ from __future__ import annotations
3
3
  import os
4
4
  from collections.abc import Callable
5
5
  from functools import wraps
6
- from typing import Literal, TypeAlias
6
+ from typing import Literal, ParamSpec, TypeAlias, TypeVar, cast
7
7
 
8
- TestMarker: TypeAlias = Literal["online", "integration"]
8
+ P = ParamSpec("P")
9
+ T = TypeVar("T")
10
+
11
+ TestMarker: TypeAlias = Literal["online", "integration", "slow"]
9
12
  """
10
13
  Valid markers for tests. Currently just marking online tests (e.g. LLM APIs that
11
14
  that require keys) and more complex integration tests.
12
15
  """
13
16
 
14
17
 
15
- def enable_if(marker: TestMarker) -> Callable:
18
+ def enable_if(marker: TestMarker) -> Callable[[Callable[P, T]], Callable[P, T]]:
16
19
  """
17
20
  Mark a test as having external dependencies.
18
21
 
@@ -34,13 +37,13 @@ def enable_if(marker: TestMarker) -> Callable:
34
37
  ```
35
38
  """
36
39
 
37
- def decorator(func: Callable) -> Callable:
40
+ def decorator(func: Callable[P, T]) -> Callable[P, T]:
38
41
  @wraps(func)
39
- def wrapper(*args, **kwargs):
42
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
40
43
  env_var = f"ENABLE_TESTS_{marker.upper()}"
41
44
  if not os.getenv(env_var):
42
45
  print(f"Skipping test function: {func.__name__} (set {env_var}=1 to enable)")
43
- return
46
+ return cast(T, None)
44
47
  return func(*args, **kwargs)
45
48
 
46
49
  # Set pytest markers automatically if pytest is available
@@ -53,6 +56,6 @@ def enable_if(marker: TestMarker) -> Callable:
53
56
  # Pytest not available, which is fine for non-test runs
54
57
  pass
55
58
 
56
- return wrapper
59
+ return wrapper # type: ignore[return-value]
57
60
 
58
61
  return decorator