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
@@ -0,0 +1,347 @@
1
+ import re
2
+ from dataclasses import dataclass, field
3
+ from textwrap import dedent
4
+
5
+
6
+ @dataclass
7
+ class Docstring:
8
+ """
9
+ A parsed docstring.
10
+ """
11
+
12
+ body: str = ""
13
+ param: dict[str, str] = field(default_factory=dict)
14
+ type: dict[str, str] = field(default_factory=dict)
15
+ returns: str = ""
16
+ rtype: str = ""
17
+
18
+
19
+ def parse_docstring(docstring: str) -> Docstring:
20
+ """
21
+ Parse a docstring in either reStructuredText or Google style format.
22
+
23
+ Supports two formats:
24
+ - reStructuredText style: `:param name: description`, `:type name: type`, etc.
25
+ - Google style: `Args:` section with `name (type): description` format
26
+
27
+ The parser automatically detects which format is used based on the presence
28
+ of `:param` directives or `Args:` sections.
29
+ """
30
+ docstring = dedent(docstring).strip()
31
+
32
+ if not docstring:
33
+ return Docstring()
34
+
35
+ # Detect format based on content
36
+ if ":param " in docstring or ":type " in docstring or ":return" in docstring:
37
+ return _parse_rst_docstring(docstring)
38
+ elif re.search(r"\b(Args|Arguments|Returns?):", docstring):
39
+ return _parse_google_docstring(docstring)
40
+ else:
41
+ # No special formatting, just treat as body
42
+ return Docstring(body=docstring)
43
+
44
+
45
+ def _parse_rst_docstring(docstring: str) -> Docstring:
46
+ """
47
+ Parse reStructuredText-style docstring with :param: and :type: directives.
48
+ """
49
+ lines = docstring.split("\n")
50
+
51
+ result = Docstring()
52
+ body_lines = []
53
+
54
+ for line in lines:
55
+ if line.strip().startswith(":"):
56
+ break
57
+ body_lines.append(line)
58
+
59
+ result.body = "\n".join(body_lines).strip()
60
+ _parse_rst_fields(lines[len(body_lines) :], result)
61
+ return result
62
+
63
+
64
+ def _parse_google_docstring(docstring: str) -> Docstring:
65
+ """
66
+ Parse Google-style docstring with Args: and Returns: sections.
67
+ """
68
+ lines = docstring.split("\n")
69
+ result = Docstring()
70
+
71
+ # Find sections using regex
72
+ sections = {}
73
+ for i, line in enumerate(lines):
74
+ stripped = line.strip()
75
+ if re.match(r"^(Args|Arguments):\s*$", stripped, re.IGNORECASE):
76
+ sections["args"] = i
77
+ elif re.match(r"^Returns?:\s*$", stripped, re.IGNORECASE):
78
+ sections["returns"] = i
79
+
80
+ # Body is everything before the first section
81
+ body_end = min(sections.values()) if sections else len(lines)
82
+ result.body = "\n".join(lines[:body_end]).strip()
83
+
84
+ # Parse each section
85
+ if "args" in sections:
86
+ _parse_google_args_section(lines, sections["args"] + 1, result, sections)
87
+ if "returns" in sections:
88
+ _parse_google_returns_section(lines, sections["returns"] + 1, result, sections)
89
+
90
+ return result
91
+
92
+
93
+ def _parse_google_args_section(
94
+ lines: list[str], start_idx: int, result: Docstring, sections: dict[str, int]
95
+ ) -> None:
96
+ """
97
+ Parse the Args: section of a Google-style docstring.
98
+ """
99
+ # Find the end of this section
100
+ end_idx = len(lines)
101
+ for section_start in sections.values():
102
+ if section_start > start_idx:
103
+ end_idx = min(end_idx, section_start)
104
+
105
+ # Determine base indentation from first non-empty line
106
+ base_indent = None
107
+ for i in range(start_idx, end_idx):
108
+ line = lines[i]
109
+ if line.strip():
110
+ base_indent = len(line) - len(line.lstrip())
111
+ break
112
+
113
+ if base_indent is None:
114
+ return
115
+
116
+ i = start_idx
117
+ while i < end_idx:
118
+ line = lines[i]
119
+
120
+ # Skip empty lines
121
+ if not line.strip():
122
+ i += 1
123
+ continue
124
+
125
+ # Check if this line is at the base indentation level (parameter line)
126
+ line_indent = len(line) - len(line.lstrip())
127
+ if line_indent == base_indent:
128
+ param_line = line.strip()
129
+
130
+ # More robust regex that allows underscores and handles various formats
131
+ # Match: name (type): description
132
+ match = re.match(r"([a-zA-Z_]\w*)\s*\(([^)]+)\)\s*:\s*(.*)", param_line)
133
+ if match:
134
+ name, param_type, description = match.groups()
135
+ result.param[name] = description.strip()
136
+ result.type[name] = param_type.strip()
137
+ else:
138
+ # Match: name: description
139
+ match = re.match(r"([a-zA-Z_]\w*)\s*:\s*(.*)", param_line)
140
+ if match:
141
+ name, description = match.groups()
142
+ result.param[name] = description.strip()
143
+
144
+ # Collect continuation lines (more indented than base)
145
+ i += 1
146
+ continuation_lines = []
147
+ while i < end_idx:
148
+ if not lines[i].strip():
149
+ i += 1
150
+ continue
151
+ next_indent = len(lines[i]) - len(lines[i].lstrip())
152
+ if next_indent > base_indent:
153
+ continuation_lines.append(lines[i].strip())
154
+ i += 1
155
+ else:
156
+ break
157
+
158
+ # Add continuation to the last parameter
159
+ if continuation_lines and result.param:
160
+ last_param = list(result.param.keys())[-1]
161
+ result.param[last_param] += " " + " ".join(continuation_lines)
162
+ else:
163
+ i += 1
164
+
165
+
166
+ def _parse_google_returns_section(
167
+ lines: list[str], start_idx: int, result: Docstring, sections: dict[str, int]
168
+ ) -> None:
169
+ """
170
+ Parse the Returns: section of a Google-style docstring.
171
+ """
172
+ # Find the end of this section
173
+ end_idx = len(lines)
174
+ for section_start in sections.values():
175
+ if section_start > start_idx:
176
+ end_idx = min(end_idx, section_start)
177
+
178
+ # Collect all content from this section
179
+ content_lines = []
180
+ for i in range(start_idx, end_idx):
181
+ line = lines[i]
182
+ if line.strip():
183
+ content_lines.append(line.strip())
184
+
185
+ if content_lines:
186
+ content = " ".join(content_lines).strip()
187
+
188
+ # Try to parse "type: description" format
189
+ if ":" in content and not content.startswith(":"):
190
+ parts = content.split(":", 1)
191
+ if len(parts) == 2 and parts[0].strip():
192
+ result.rtype = parts[0].strip()
193
+ result.returns = parts[1].strip()
194
+ else:
195
+ result.returns = content
196
+ else:
197
+ result.returns = content
198
+
199
+
200
+ def _parse_rst_fields(lines: list[str], result: Docstring) -> None:
201
+ """Parse reStructuredText-style field directives."""
202
+ current_field = None
203
+ current_content = []
204
+
205
+ def save_current_field():
206
+ if current_field and current_content:
207
+ content = " ".join(current_content).strip()
208
+ if current_field.startswith("param "):
209
+ result.param[current_field[6:]] = content
210
+ elif current_field.startswith("type "):
211
+ result.type[current_field[5:]] = content
212
+ elif current_field == "return":
213
+ result.returns = content
214
+ elif current_field == "rtype":
215
+ result.rtype = content
216
+
217
+ for line in lines:
218
+ if line.strip().startswith(":"):
219
+ save_current_field()
220
+ current_field, _, content = line.strip()[1:].partition(":")
221
+ current_content = [content.strip()]
222
+ else:
223
+ current_content.append(line.strip())
224
+
225
+ save_current_field()
226
+
227
+
228
+ ## Tests
229
+
230
+
231
+ def test_parse_rst_docstring():
232
+ rst_docstring = """
233
+ Search for a string in files at the given paths and return their store paths.
234
+ Useful to find all docs or resources matching a string or regex.
235
+
236
+ :param sort: How to sort results. Can be `path` or `score`.
237
+ :param ignore_case: Ignore case when searching.
238
+ :type sort: str
239
+ :type ignore_case: bool
240
+ :return: The search results.
241
+ :rtype: CommandOutput
242
+ """
243
+
244
+ parsed = parse_docstring(rst_docstring)
245
+
246
+ assert (
247
+ parsed.body
248
+ == "Search for a string in files at the given paths and return their store paths.\nUseful to find all docs or resources matching a string or regex."
249
+ )
250
+ assert parsed.param == {
251
+ "sort": "How to sort results. Can be `path` or `score`.",
252
+ "ignore_case": "Ignore case when searching.",
253
+ }
254
+ assert parsed.type == {"sort": "str", "ignore_case": "bool"}
255
+ assert parsed.returns == "The search results."
256
+ assert parsed.rtype == "CommandOutput"
257
+
258
+
259
+ def test_parse_google_docstring_with_types():
260
+ google_docstring = """
261
+ Search for a string in files at the given paths and return their store paths.
262
+ Useful to find all docs or resources matching a string or regex.
263
+
264
+ Args:
265
+ sort (str): How to sort results. Can be `path` or `score`.
266
+ ignore_case (bool): Ignore case when searching.
267
+
268
+ Returns:
269
+ CommandOutput: The search results.
270
+ """
271
+
272
+ parsed = parse_docstring(google_docstring)
273
+
274
+ assert (
275
+ parsed.body
276
+ == "Search for a string in files at the given paths and return their store paths.\nUseful to find all docs or resources matching a string or regex."
277
+ )
278
+ assert parsed.param == {
279
+ "sort": "How to sort results. Can be `path` or `score`.",
280
+ "ignore_case": "Ignore case when searching.",
281
+ }
282
+ assert parsed.type == {"sort": "str", "ignore_case": "bool"}
283
+ assert parsed.returns == "The search results."
284
+ assert parsed.rtype == "CommandOutput"
285
+
286
+
287
+ def test_parse_google_docstring_without_types():
288
+ google_no_types = """
289
+ Process the data.
290
+
291
+ Args:
292
+ data: The input data to process.
293
+ verbose: Whether to print verbose output.
294
+
295
+ Returns:
296
+ The processed result.
297
+ """
298
+
299
+ parsed = parse_docstring(google_no_types)
300
+
301
+ assert parsed.body == "Process the data."
302
+ assert parsed.param == {
303
+ "data": "The input data to process.",
304
+ "verbose": "Whether to print verbose output.",
305
+ }
306
+ assert parsed.type == {}
307
+ assert parsed.returns == "The processed result."
308
+ assert parsed.rtype == ""
309
+
310
+
311
+ def test_parse_simple_docstring():
312
+ simple_docstring = """Some text."""
313
+ parsed = parse_docstring(simple_docstring)
314
+
315
+ assert parsed.body == "Some text."
316
+ assert parsed.param == {}
317
+ assert parsed.type == {}
318
+ assert parsed.returns == ""
319
+ assert parsed.rtype == ""
320
+
321
+
322
+ def test_parse_docstring_with_underscores():
323
+ docstring = """
324
+ Test function.
325
+
326
+ Args:
327
+ some_param (str): A parameter with underscores.
328
+ another_param_name: Another parameter without type.
329
+ """
330
+
331
+ parsed = parse_docstring(docstring)
332
+
333
+ assert parsed.param == {
334
+ "some_param": "A parameter with underscores.",
335
+ "another_param_name": "Another parameter without type.",
336
+ }
337
+ assert parsed.type == {"some_param": "str"}
338
+
339
+
340
+ def test_parse_empty_docstring():
341
+ """Test empty docstring handling."""
342
+ parsed = parse_docstring("")
343
+ assert parsed.body == ""
344
+ assert parsed.param == {}
345
+ assert parsed.type == {}
346
+ assert parsed.returns == ""
347
+ assert parsed.rtype == ""
@@ -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