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.
Files changed (43) hide show
  1. kash/actions/core/{markdownify.py → markdownify_html.py} +3 -6
  2. kash/commands/workspace/workspace_commands.py +10 -88
  3. kash/config/colors.py +8 -6
  4. kash/config/text_styles.py +2 -0
  5. kash/docs/markdown/topics/a1_what_is_kash.md +1 -1
  6. kash/docs/markdown/topics/b1_kash_overview.md +34 -45
  7. kash/exec/__init__.py +3 -0
  8. kash/exec/action_decorators.py +20 -5
  9. kash/exec/action_exec.py +2 -2
  10. kash/exec/{fetch_url_metadata.py → fetch_url_items.py} +42 -14
  11. kash/exec/llm_transforms.py +1 -1
  12. kash/exec/shell_callable_action.py +1 -1
  13. kash/file_storage/file_store.py +7 -1
  14. kash/file_storage/store_filenames.py +4 -0
  15. kash/help/function_param_info.py +1 -1
  16. kash/help/help_pages.py +1 -1
  17. kash/help/help_printing.py +1 -1
  18. kash/llm_utils/llm_completion.py +1 -1
  19. kash/model/actions_model.py +6 -0
  20. kash/model/items_model.py +18 -3
  21. kash/shell/output/shell_output.py +15 -0
  22. kash/utils/api_utils/api_retries.py +305 -0
  23. kash/utils/api_utils/cache_requests_limited.py +84 -0
  24. kash/utils/api_utils/gather_limited.py +987 -0
  25. kash/utils/api_utils/progress_protocol.py +299 -0
  26. kash/utils/common/function_inspect.py +66 -1
  27. kash/utils/common/parse_docstring.py +347 -0
  28. kash/utils/common/testing.py +10 -7
  29. kash/utils/rich_custom/multitask_status.py +631 -0
  30. kash/utils/text_handling/escape_html_tags.py +16 -11
  31. kash/utils/text_handling/markdown_render.py +1 -0
  32. kash/web_content/web_extract.py +34 -15
  33. kash/web_content/web_page_model.py +10 -1
  34. kash/web_gen/templates/base_styles.css.jinja +26 -20
  35. kash/web_gen/templates/components/toc_styles.css.jinja +1 -1
  36. kash/web_gen/templates/components/tooltip_scripts.js.jinja +171 -19
  37. kash/web_gen/templates/components/tooltip_styles.css.jinja +23 -8
  38. {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/METADATA +4 -2
  39. {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/RECORD +42 -37
  40. kash/help/docstring_utils.py +0 -111
  41. {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/WHEEL +0 -0
  42. {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/entry_points.txt +0 -0
  43. {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/licenses/LICENSE +0 -0
kash/model/items_model.py CHANGED
@@ -675,9 +675,21 @@ class Item:
675
675
  raise FileFormatError(f"Config item is not YAML: {self.format}: {self}")
676
676
  return from_yaml_string(self.body)
677
677
 
678
+ def get_filename(self) -> str | None:
679
+ """
680
+ Get the store or external path filename of the item, including the
681
+ file extension.
682
+ """
683
+ if self.store_path:
684
+ return Path(self.store_path).name
685
+ elif self.external_path:
686
+ return Path(self.external_path).name
687
+ else:
688
+ return None
689
+
678
690
  def get_file_ext(self) -> FileExt:
679
691
  """
680
- Get or infer file extension.
692
+ Get or infer the base file extension for the item.
681
693
  """
682
694
  if self.file_ext:
683
695
  return self.file_ext
@@ -688,7 +700,8 @@ class Item:
688
700
 
689
701
  def get_full_suffix(self) -> str:
690
702
  """
691
- Get the full file extension suffix (e.g. "note.md") for this item.
703
+ Assemble the full file extension suffix (e.g. "resource.yml") for this item.
704
+ Without a leading dot.
692
705
  """
693
706
  if self.type == ItemType.extension:
694
707
  # Python files cannot have more than one . in them.
@@ -892,12 +905,14 @@ class Item:
892
905
 
893
906
  def fmt_loc(self) -> str:
894
907
  """
895
- Formatted store path, external path, or title. For error messages etc.
908
+ Formatted store path, external path, URL, or title. Use for logging etc.
896
909
  """
897
910
  if self.store_path:
898
911
  return fmt_store_path(self.store_path)
899
912
  elif self.external_path:
900
913
  return fmt_loc(self.external_path)
914
+ elif self.url:
915
+ return fmt_loc(self.url)
901
916
  else:
902
917
  return repr(self.pick_title())
903
918
 
@@ -28,6 +28,7 @@ from kash.config.text_styles import (
28
28
  STYLE_HINT,
29
29
  )
30
30
  from kash.shell.output.kmarkdown import KMarkdown
31
+ from kash.utils.rich_custom.multitask_status import MultiTaskStatus, StatusSettings
31
32
  from kash.utils.rich_custom.rich_indent import Indent
32
33
  from kash.utils.rich_custom.rich_markdown_fork import Markdown
33
34
 
@@ -80,6 +81,20 @@ def console_pager(use_pager: bool = True):
80
81
  PrintHooks.after_pager()
81
82
 
82
83
 
84
+ def multitask_status(
85
+ settings: StatusSettings | None = None, *, auto_summary: bool = True
86
+ ) -> MultiTaskStatus:
87
+ """
88
+ Create a `MultiTaskStatus` context manager for displaying multiple task progress
89
+ using the global shell console.
90
+ """
91
+ return MultiTaskStatus(
92
+ console=get_console(),
93
+ settings=settings,
94
+ auto_summary=auto_summary,
95
+ )
96
+
97
+
83
98
  null_style = rich.style.Style.null()
84
99
 
85
100
 
@@ -0,0 +1,305 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+
7
+
8
+ class RetryException(RuntimeError):
9
+ """
10
+ Base exception class for retry-related errors.
11
+ """
12
+
13
+
14
+ class RetryExhaustedException(RetryException):
15
+ """
16
+ Retries exhausted (this is not retriable).
17
+ """
18
+
19
+ def __init__(self, original_exception: Exception, max_retries: int, total_time: float):
20
+ self.original_exception = original_exception
21
+ self.max_retries = max_retries
22
+ self.total_time = total_time
23
+
24
+ super().__init__(
25
+ f"Max retries ({max_retries}) exhausted after {total_time:.1f}s. "
26
+ f"Final error: {type(original_exception).__name__}: {original_exception}"
27
+ )
28
+
29
+
30
+ def default_is_retriable(exception: Exception) -> bool:
31
+ """
32
+ Default retriable exception checker for common rate limit patterns.
33
+
34
+ Args:
35
+ exception: The exception to check
36
+
37
+ Returns:
38
+ True if the exception should be retried with backoff
39
+ """
40
+ # Check for LiteLLM specific exceptions first, as a soft dependency.
41
+ try:
42
+ import litellm.exceptions
43
+
44
+ # Check for specific LiteLLM exception types
45
+ if isinstance(
46
+ exception,
47
+ (
48
+ litellm.exceptions.RateLimitError,
49
+ litellm.exceptions.APIError,
50
+ ),
51
+ ):
52
+ return True
53
+ except ImportError:
54
+ # LiteLLM not available, fall back to string-based detection
55
+ pass
56
+
57
+ # Fallback to string-based detection for general patterns
58
+ exception_str = str(exception).lower()
59
+ rate_limit_indicators = [
60
+ "rate limit",
61
+ "too many requests",
62
+ "try again later",
63
+ "429",
64
+ "quota exceeded",
65
+ "throttled",
66
+ "rate_limit_error",
67
+ "ratelimiterror",
68
+ ]
69
+
70
+ return any(indicator in exception_str for indicator in rate_limit_indicators)
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class RetrySettings:
75
+ """
76
+ Retry behavior when handling concurrent requests.
77
+ """
78
+
79
+ max_task_retries: int
80
+ """Maximum retries per individual task (0 = no retries)"""
81
+
82
+ max_total_retries: int | None = None
83
+ """Maximum retries across all tasks combined (None = no global limit)"""
84
+
85
+ initial_backoff: float = 1.0
86
+ """Base backoff time in seconds"""
87
+
88
+ max_backoff: float = 128.0
89
+ """Maximum backoff time in seconds"""
90
+
91
+ backoff_factor: float = 2.0
92
+ """Exponential backoff multiplier"""
93
+
94
+ is_retriable: Callable[[Exception], bool] = default_is_retriable
95
+ """Function to determine if an exception should be retried"""
96
+
97
+
98
+ DEFAULT_RETRIES = RetrySettings(
99
+ max_task_retries=10,
100
+ max_total_retries=100,
101
+ initial_backoff=1.0,
102
+ max_backoff=128.0,
103
+ backoff_factor=2.0,
104
+ is_retriable=default_is_retriable,
105
+ )
106
+ """Reasonable default retry settings with both per-task and global limits."""
107
+
108
+
109
+ NO_RETRIES = RetrySettings(
110
+ max_task_retries=0,
111
+ max_total_retries=0,
112
+ initial_backoff=0.0,
113
+ max_backoff=0.0,
114
+ backoff_factor=1.0,
115
+ is_retriable=lambda _: False,
116
+ )
117
+ """Disable retries completely."""
118
+
119
+
120
+ def extract_retry_after(exception: Exception) -> float | None:
121
+ """
122
+ Try to extract retry-after time from exception headers or message.
123
+
124
+ Args:
125
+ exception: The exception to extract retry-after from
126
+
127
+ Returns:
128
+ Retry-after time in seconds, or None if not found
129
+ """
130
+ # Check if exception has response headers
131
+ response = getattr(exception, "response", None)
132
+ if response:
133
+ headers = getattr(response, "headers", None)
134
+ if headers and "retry-after" in headers:
135
+ try:
136
+ return float(headers["retry-after"])
137
+ except (ValueError, TypeError):
138
+ pass
139
+
140
+ # Check for retry_after attribute
141
+ retry_after = getattr(exception, "retry_after", None)
142
+ if retry_after is not None:
143
+ try:
144
+ return float(retry_after)
145
+ except (ValueError, TypeError):
146
+ pass
147
+
148
+ return None
149
+
150
+
151
+ def calculate_backoff(
152
+ attempt: int,
153
+ exception: Exception,
154
+ *,
155
+ initial_backoff: float,
156
+ max_backoff: float,
157
+ backoff_factor: float,
158
+ ) -> float:
159
+ """
160
+ Calculate backoff time using exponential backoff with jitter.
161
+
162
+ Args:
163
+ attempt: Current attempt number (0-based)
164
+ exception: The exception that triggered the backoff
165
+ initial_backoff: Base backoff time in seconds
166
+ max_backoff: Maximum backoff time in seconds
167
+ backoff_factor: Exponential backoff multiplier
168
+
169
+ Returns:
170
+ Backoff time in seconds
171
+ """
172
+ # Try to extract retry-after header if available
173
+ retry_after = extract_retry_after(exception)
174
+ if retry_after is not None:
175
+ return min(retry_after, max_backoff)
176
+
177
+ # Exponential backoff: initial_backoff * (backoff_factor ^ attempt)
178
+ exponential_backoff = initial_backoff * (backoff_factor**attempt)
179
+
180
+ # Add significant jitter (±50% randomization) to prevent thundering herd
181
+ jitter_factor = 1 + (random.random() - 0.5) * 1.0
182
+ backoff_with_jitter = exponential_backoff * jitter_factor
183
+ # Add a small random base delay (0 to 50% of initial_backoff) to further spread out retries
184
+ base_delay = random.random() * (initial_backoff * 0.5)
185
+ total_backoff = backoff_with_jitter + base_delay
186
+
187
+ return min(total_backoff, max_backoff)
188
+
189
+
190
+ ## Tests
191
+
192
+
193
+ def test_default_is_retriable():
194
+ """Test string-based rate limit detection."""
195
+ # Positive cases
196
+ assert default_is_retriable(Exception("Rate limit exceeded"))
197
+ assert default_is_retriable(Exception("Too many requests"))
198
+ assert default_is_retriable(Exception("HTTP 429 error"))
199
+ assert default_is_retriable(Exception("Quota exceeded"))
200
+ assert default_is_retriable(Exception("throttled"))
201
+ assert default_is_retriable(Exception("RateLimitError"))
202
+
203
+ # Negative cases
204
+ assert not default_is_retriable(Exception("Authentication failed"))
205
+ assert not default_is_retriable(Exception("Invalid API key"))
206
+ assert not default_is_retriable(Exception("Network error"))
207
+
208
+
209
+ def test_default_is_retriable_litellm():
210
+ """Test LiteLLM exception detection if available."""
211
+ try:
212
+ import litellm.exceptions
213
+
214
+ # Test retriable LiteLLM exceptions
215
+ rate_error = litellm.exceptions.RateLimitError(
216
+ message="Rate limit", model="test", llm_provider="test"
217
+ )
218
+ api_error = litellm.exceptions.APIError(
219
+ message="API error", model="test", llm_provider="test", status_code=500
220
+ )
221
+ assert default_is_retriable(rate_error)
222
+ assert default_is_retriable(api_error)
223
+
224
+ # Test non-retriable exception
225
+ auth_error = litellm.exceptions.AuthenticationError(
226
+ message="Auth failed", model="test", llm_provider="test"
227
+ )
228
+ assert not default_is_retriable(auth_error)
229
+
230
+ except ImportError:
231
+ # LiteLLM not available, skip
232
+ pass
233
+
234
+
235
+ def test_extract_retry_after():
236
+ """Test retry-after header extraction."""
237
+
238
+ class MockResponse:
239
+ def __init__(self, headers):
240
+ self.headers = headers
241
+
242
+ class MockException(Exception):
243
+ def __init__(self, response=None, retry_after=None):
244
+ self.response = response
245
+ if retry_after is not None:
246
+ self.retry_after = retry_after
247
+ super().__init__()
248
+
249
+ # Test response header
250
+ response = MockResponse({"retry-after": "30"})
251
+ assert extract_retry_after(MockException(response=response)) == 30.0
252
+
253
+ # Test retry_after attribute
254
+ assert extract_retry_after(MockException(retry_after=45.0)) == 45.0
255
+
256
+ # Test no retry info
257
+ assert extract_retry_after(MockException()) is None
258
+
259
+ # Test invalid values
260
+ invalid_response = MockResponse({"retry-after": "invalid"})
261
+ assert extract_retry_after(MockException(response=invalid_response)) is None
262
+
263
+
264
+ def test_calculate_backoff():
265
+ """Test backoff calculation."""
266
+
267
+ class MockException(Exception):
268
+ def __init__(self, retry_after=None):
269
+ self.retry_after = retry_after
270
+ super().__init__()
271
+
272
+ # Test with retry_after header
273
+ exception = MockException(retry_after=30.0)
274
+ assert (
275
+ calculate_backoff(
276
+ attempt=1,
277
+ exception=exception,
278
+ initial_backoff=1.0,
279
+ max_backoff=60.0,
280
+ backoff_factor=2.0,
281
+ )
282
+ == 30.0
283
+ )
284
+
285
+ # Test exponential backoff with increased jitter and base delay
286
+ exception = MockException()
287
+ backoff = calculate_backoff(
288
+ attempt=1,
289
+ exception=exception,
290
+ initial_backoff=1.0,
291
+ max_backoff=60.0,
292
+ backoff_factor=2.0,
293
+ )
294
+ # base factor * (±50% jitter) + (0-50% of initial_backoff) = range calculation
295
+ assert 1.0 <= backoff <= 3.5
296
+
297
+ # Test max_backoff cap
298
+ high_backoff = calculate_backoff(
299
+ attempt=10,
300
+ exception=exception,
301
+ initial_backoff=1.0,
302
+ max_backoff=5.0,
303
+ backoff_factor=2.0,
304
+ )
305
+ assert high_backoff <= 5.0
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+ from urllib.parse import urlencode
6
+
7
+ import requests
8
+ from pyrate_limiter import Duration, Limiter, Rate
9
+ from pyrate_limiter.buckets import InMemoryBucket
10
+ from typing_extensions import override
11
+
12
+ from kash.config.logger import get_logger
13
+ from kash.web_content.file_cache_utils import cache_file
14
+ from kash.web_content.local_file_cache import Loadable
15
+
16
+ log = get_logger(__name__)
17
+
18
+
19
+ class CachingSession(requests.Session):
20
+ """
21
+ A `requests.Session` that adds local file caching and optional rate limiting (if
22
+ `limit` and `limit_interval_secs` are provided). A bit of a hack but enables
23
+ hot patching libraries that use `requests` without other code changes.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ limit: int | None = None,
30
+ limit_interval_secs: int | None = None,
31
+ max_wait_secs: int = 60 * 5,
32
+ ):
33
+ super().__init__()
34
+ self._limiter: Limiter | None = None
35
+ if limit and limit_interval_secs:
36
+ rate = Rate(limit, Duration.SECOND * limit_interval_secs)
37
+ bucket = InMemoryBucket([rate])
38
+ # Explicitly set raise_when_fail=False and max_delay to enable blocking.
39
+ self._limiter = Limiter(
40
+ bucket, raise_when_fail=False, max_delay=Duration.SECOND * max_wait_secs
41
+ )
42
+ log.info(
43
+ "CachingSession: rate limiting requests with limit=%d, interval=%d, max_wait=%d",
44
+ limit,
45
+ limit_interval_secs,
46
+ max_wait_secs,
47
+ )
48
+
49
+ @override
50
+ def get(self, url: str | bytes, **kwargs: Any) -> Any:
51
+ params = kwargs.get("params")
52
+ # We need a unique key for the cache, so we use the URL and params.
53
+ url_str = url.decode() if isinstance(url, bytes) else str(url)
54
+ query_string = urlencode(params or {})
55
+ url_key = f"{url_str}?{query_string}" if query_string else url_str
56
+
57
+ def save(path: Path):
58
+ if self._limiter:
59
+ acquired = self._limiter.try_acquire("caching_session_get")
60
+ if not acquired:
61
+ # Generally shouldn't happen.
62
+ raise RuntimeError("Rate limiter failed to acquire after maximum delay")
63
+
64
+ response = super(CachingSession, self).get(url, **kwargs)
65
+ response.raise_for_status()
66
+ content = response.content
67
+ with open(path, "wb") as f:
68
+ f.write(content)
69
+
70
+ cache_result = cache_file(Loadable(url_key, save))
71
+
72
+ if not cache_result.was_cached:
73
+ log.debug("Cache miss, fetched: %s", url_key)
74
+ else:
75
+ log.debug("Cache hit: %s", url_key)
76
+
77
+ # A simple hack to make sure response.json() works (e.g. when using wikipediaapi needs).
78
+ # TODO: Wrap more carefully to ensure other methods work.
79
+ response = requests.Response()
80
+ response.status_code = 200
81
+ response.encoding = "utf-8"
82
+ response._content = cache_result.content.path.read_bytes()
83
+ response.url = url_key
84
+ return response