kash-shell 0.3.17__py3-none-any.whl → 0.3.20__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/actions/core/minify_html.py +41 -0
- kash/commands/base/show_command.py +11 -1
- kash/commands/workspace/workspace_commands.py +10 -88
- kash/config/colors.py +6 -2
- kash/docs/markdown/topics/a1_what_is_kash.md +52 -23
- kash/docs/markdown/topics/a2_installation.md +17 -30
- kash/docs/markdown/topics/a3_getting_started.md +5 -19
- kash/exec/__init__.py +3 -0
- kash/exec/action_exec.py +3 -3
- kash/exec/fetch_url_items.py +109 -0
- kash/exec/precondition_registry.py +3 -3
- kash/file_storage/file_store.py +24 -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_features.py +5 -1
- kash/llm_utils/llms.py +18 -8
- kash/media_base/media_cache.py +48 -24
- kash/media_base/media_services.py +63 -14
- kash/media_base/services/local_file_media.py +9 -1
- kash/model/items_model.py +22 -8
- kash/model/media_model.py +9 -1
- kash/model/params_model.py +9 -3
- kash/utils/common/function_inspect.py +97 -1
- kash/utils/common/parse_docstring.py +347 -0
- kash/utils/common/testing.py +58 -0
- kash/utils/common/url_slice.py +329 -0
- kash/utils/file_utils/file_formats.py +1 -1
- kash/utils/text_handling/markdown_utils.py +424 -16
- 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 +137 -15
- kash/web_gen/templates/base_webpage.html.jinja +13 -17
- kash/web_gen/templates/components/toc_scripts.js.jinja +319 -0
- kash/web_gen/templates/components/toc_styles.css.jinja +284 -0
- kash/web_gen/templates/components/tooltip_scripts.js.jinja +730 -0
- kash/web_gen/templates/components/tooltip_styles.css.jinja +482 -0
- kash/web_gen/templates/content_styles.css.jinja +13 -8
- kash/web_gen/templates/simple_webpage.html.jinja +15 -481
- kash/workspaces/workspaces.py +10 -1
- {kash_shell-0.3.17.dist-info → kash_shell-0.3.20.dist-info}/METADATA +75 -72
- {kash_shell-0.3.17.dist-info → kash_shell-0.3.20.dist-info}/RECORD +47 -40
- kash/exec/fetch_url_metadata.py +0 -72
- kash/help/docstring_utils.py +0 -111
- {kash_shell-0.3.17.dist-info → kash_shell-0.3.20.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.17.dist-info → kash_shell-0.3.20.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.17.dist-info → kash_shell-0.3.20.dist-info}/licenses/LICENSE +0 -0
kash/model/params_model.py
CHANGED
|
@@ -206,10 +206,10 @@ A list of parameter declarations, possibly with default values.
|
|
|
206
206
|
|
|
207
207
|
# These are the default models for typical use cases.
|
|
208
208
|
# The user may override them with parameters.
|
|
209
|
-
DEFAULT_CAREFUL_LLM = LLM.
|
|
209
|
+
DEFAULT_CAREFUL_LLM = LLM.o3
|
|
210
210
|
DEFAULT_STRUCTURED_LLM = LLM.gpt_4o
|
|
211
|
-
DEFAULT_STANDARD_LLM = LLM.
|
|
212
|
-
DEFAULT_FAST_LLM = LLM.
|
|
211
|
+
DEFAULT_STANDARD_LLM = LLM.claude_4_sonnet
|
|
212
|
+
DEFAULT_FAST_LLM = LLM.o1_mini
|
|
213
213
|
|
|
214
214
|
|
|
215
215
|
# Parameters set globally such as in the workspace.
|
|
@@ -262,6 +262,12 @@ COMMON_ACTION_PARAMS: dict[str, Param] = {
|
|
|
262
262
|
valid_str_values=list(LLM),
|
|
263
263
|
is_open_ended=True,
|
|
264
264
|
),
|
|
265
|
+
"model_list": Param(
|
|
266
|
+
"model_list",
|
|
267
|
+
"A list of LLMs to use, as names separated by commas.",
|
|
268
|
+
type=str,
|
|
269
|
+
default_value=None,
|
|
270
|
+
),
|
|
265
271
|
"language": Param(
|
|
266
272
|
"language",
|
|
267
273
|
"The language of the input audio or text.",
|
|
@@ -4,7 +4,14 @@ from collections.abc import Callable
|
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from enum import Enum
|
|
6
6
|
from inspect import Parameter
|
|
7
|
-
from typing import
|
|
7
|
+
from typing import (
|
|
8
|
+
Any,
|
|
9
|
+
Literal,
|
|
10
|
+
Union, # pyright: ignore[reportDeprecated]
|
|
11
|
+
cast,
|
|
12
|
+
get_args,
|
|
13
|
+
get_origin,
|
|
14
|
+
)
|
|
8
15
|
|
|
9
16
|
NO_DEFAULT = Parameter.empty # Alias for clarity
|
|
10
17
|
|
|
@@ -90,6 +97,23 @@ def _resolve_type_details(annotation: Any) -> tuple[type | None, type | None, bo
|
|
|
90
97
|
return (type(None), None, True)
|
|
91
98
|
# If multiple non_none_args (e.g., int | str), current_annotation remains the Union for now.
|
|
92
99
|
|
|
100
|
+
# Handle Literal types
|
|
101
|
+
if origin is Literal:
|
|
102
|
+
if args:
|
|
103
|
+
# Determine the common type of all literal values
|
|
104
|
+
literal_types = {type(arg) for arg in args}
|
|
105
|
+
if len(literal_types) == 1:
|
|
106
|
+
# All literals are the same type
|
|
107
|
+
final_effective_type = literal_types.pop()
|
|
108
|
+
else:
|
|
109
|
+
# Mixed types, fall back to the most common base type or str if all are basic types
|
|
110
|
+
if all(isinstance(arg, (str, int, float, bool)) for arg in args):
|
|
111
|
+
# For mixed basic types, use str as the effective type
|
|
112
|
+
final_effective_type = str
|
|
113
|
+
else:
|
|
114
|
+
final_effective_type = None
|
|
115
|
+
return final_effective_type, None, is_optional_flag
|
|
116
|
+
|
|
93
117
|
# Determine effective_type and inner_type from (potentially unwrapped) current_annotation
|
|
94
118
|
final_effective_type: type | None = None
|
|
95
119
|
final_inner_type: type | None = None
|
|
@@ -426,3 +450,75 @@ def test_inspect_function_parameters_updated():
|
|
|
426
450
|
is_explicitly_optional=True,
|
|
427
451
|
)
|
|
428
452
|
]
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def test_literal_types():
|
|
456
|
+
"""Test Literal type support in function parameter inspection."""
|
|
457
|
+
|
|
458
|
+
# Test string literals
|
|
459
|
+
def func_string_literal(converter: Literal["markitdown", "marker"] = "markitdown"):
|
|
460
|
+
return converter
|
|
461
|
+
|
|
462
|
+
params = inspect_function_params(func_string_literal)
|
|
463
|
+
assert len(params) == 1
|
|
464
|
+
param = params[0]
|
|
465
|
+
assert param.name == "converter"
|
|
466
|
+
assert param.effective_type is str
|
|
467
|
+
assert param.default == "markitdown"
|
|
468
|
+
assert param.is_explicitly_optional is False
|
|
469
|
+
|
|
470
|
+
# Test integer literals
|
|
471
|
+
def func_int_literal(count: Literal[1, 2, 3] = 1):
|
|
472
|
+
return count
|
|
473
|
+
|
|
474
|
+
params = inspect_function_params(func_int_literal)
|
|
475
|
+
assert len(params) == 1
|
|
476
|
+
param = params[0]
|
|
477
|
+
assert param.name == "count"
|
|
478
|
+
assert param.effective_type is int
|
|
479
|
+
assert param.default == 1
|
|
480
|
+
|
|
481
|
+
# Test mixed type literals (should default to str)
|
|
482
|
+
def func_mixed_literal(value: Literal["auto", 42]):
|
|
483
|
+
return value
|
|
484
|
+
|
|
485
|
+
params = inspect_function_params(func_mixed_literal)
|
|
486
|
+
assert len(params) == 1
|
|
487
|
+
param = params[0]
|
|
488
|
+
assert param.name == "value"
|
|
489
|
+
assert param.effective_type is str
|
|
490
|
+
assert param.default == NO_DEFAULT
|
|
491
|
+
|
|
492
|
+
# Test Literal directly (without TypeAlias to avoid scope issues)
|
|
493
|
+
def func_direct_literal(converter: Literal["markitdown", "marker"] = "markitdown"):
|
|
494
|
+
return converter
|
|
495
|
+
|
|
496
|
+
params = inspect_function_params(func_direct_literal)
|
|
497
|
+
assert len(params) == 1
|
|
498
|
+
param = params[0]
|
|
499
|
+
assert param.name == "converter"
|
|
500
|
+
assert param.effective_type is str
|
|
501
|
+
assert param.default == "markitdown"
|
|
502
|
+
|
|
503
|
+
# Test optional literal
|
|
504
|
+
def func_optional_literal(mode: Literal["fast", "slow"] | None = None):
|
|
505
|
+
return mode
|
|
506
|
+
|
|
507
|
+
params = inspect_function_params(func_optional_literal)
|
|
508
|
+
assert len(params) == 1
|
|
509
|
+
param = params[0]
|
|
510
|
+
assert param.name == "mode"
|
|
511
|
+
assert param.effective_type is str
|
|
512
|
+
assert param.is_explicitly_optional is True
|
|
513
|
+
assert param.default is None
|
|
514
|
+
|
|
515
|
+
# Test boolean literals
|
|
516
|
+
def func_bool_literal(flag: Literal[True, False] = True):
|
|
517
|
+
return flag
|
|
518
|
+
|
|
519
|
+
params = inspect_function_params(func_bool_literal)
|
|
520
|
+
assert len(params) == 1
|
|
521
|
+
param = params[0]
|
|
522
|
+
assert param.name == "flag"
|
|
523
|
+
assert param.effective_type is bool
|
|
524
|
+
assert param.default is True
|
|
@@ -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 == ""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Literal, TypeAlias
|
|
7
|
+
|
|
8
|
+
TestMarker: TypeAlias = Literal["online", "integration"]
|
|
9
|
+
"""
|
|
10
|
+
Valid markers for tests. Currently just marking online tests (e.g. LLM APIs that
|
|
11
|
+
that require keys) and more complex integration tests.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def enable_if(marker: TestMarker) -> Callable:
|
|
16
|
+
"""
|
|
17
|
+
Mark a test as having external dependencies.
|
|
18
|
+
|
|
19
|
+
Test runs only if the corresponding environment variable is set, e.g.
|
|
20
|
+
for the marker "online", checks for ENABLE_TESTS_ONLINE=1.
|
|
21
|
+
|
|
22
|
+
Automatically sets pytest markers when pytest is available, but safe to use in
|
|
23
|
+
runtime code as well.
|
|
24
|
+
|
|
25
|
+
Example usage:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
def test_foo():
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
@enable_if("online") # Only runs if ENABLE_TESTS_ONLINE=1
|
|
32
|
+
def test_bar():
|
|
33
|
+
...
|
|
34
|
+
```
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def decorator(func: Callable) -> Callable:
|
|
38
|
+
@wraps(func)
|
|
39
|
+
def wrapper(*args, **kwargs):
|
|
40
|
+
env_var = f"ENABLE_TESTS_{marker.upper()}"
|
|
41
|
+
if not os.getenv(env_var):
|
|
42
|
+
print(f"Skipping test function: {func.__name__} (set {env_var}=1 to enable)")
|
|
43
|
+
return
|
|
44
|
+
return func(*args, **kwargs)
|
|
45
|
+
|
|
46
|
+
# Set pytest markers automatically if pytest is available
|
|
47
|
+
try:
|
|
48
|
+
import pytest
|
|
49
|
+
|
|
50
|
+
wrapper = pytest.mark.integration(wrapper)
|
|
51
|
+
wrapper = getattr(pytest.mark, marker)(wrapper)
|
|
52
|
+
except ImportError:
|
|
53
|
+
# Pytest not available, which is fine for non-test runs
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
return wrapper
|
|
57
|
+
|
|
58
|
+
return decorator
|