onetool-mcp 1.0.0b1__py3-none-any.whl → 1.0.0rc2__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.
- onetool/cli.py +63 -4
- onetool_mcp-1.0.0rc2.dist-info/METADATA +266 -0
- onetool_mcp-1.0.0rc2.dist-info/RECORD +129 -0
- {onetool_mcp-1.0.0b1.dist-info → onetool_mcp-1.0.0rc2.dist-info}/licenses/LICENSE.txt +1 -1
- {onetool_mcp-1.0.0b1.dist-info → onetool_mcp-1.0.0rc2.dist-info}/licenses/NOTICE.txt +54 -64
- ot/__main__.py +6 -6
- ot/config/__init__.py +48 -46
- ot/config/global_templates/__init__.py +2 -2
- ot/config/{defaults → global_templates}/diagram-templates/api-flow.mmd +33 -33
- ot/config/{defaults → global_templates}/diagram-templates/c4-context.puml +30 -30
- ot/config/{defaults → global_templates}/diagram-templates/class-diagram.mmd +87 -87
- ot/config/{defaults → global_templates}/diagram-templates/feature-mindmap.mmd +70 -70
- ot/config/{defaults → global_templates}/diagram-templates/microservices.d2 +81 -81
- ot/config/{defaults → global_templates}/diagram-templates/project-gantt.mmd +37 -37
- ot/config/{defaults → global_templates}/diagram-templates/state-machine.mmd +42 -42
- ot/config/global_templates/diagram.yaml +167 -0
- ot/config/global_templates/onetool.yaml +3 -1
- ot/config/{defaults → global_templates}/prompts.yaml +102 -97
- ot/config/global_templates/security.yaml +31 -0
- ot/config/global_templates/servers.yaml +93 -12
- ot/config/global_templates/snippets.yaml +5 -26
- ot/config/{defaults → global_templates}/tool_templates/__init__.py +7 -7
- ot/config/loader.py +221 -105
- ot/config/mcp.py +5 -1
- ot/config/secrets.py +192 -190
- ot/decorators.py +116 -116
- ot/executor/__init__.py +35 -35
- ot/executor/base.py +16 -16
- ot/executor/fence_processor.py +83 -83
- ot/executor/linter.py +142 -142
- ot/executor/pep723.py +288 -288
- ot/executor/runner.py +20 -6
- ot/executor/simple.py +163 -163
- ot/executor/validator.py +603 -164
- ot/http_client.py +145 -145
- ot/logging/__init__.py +37 -37
- ot/logging/entry.py +213 -213
- ot/logging/format.py +191 -188
- ot/logging/span.py +349 -349
- ot/meta.py +236 -14
- ot/paths.py +32 -49
- ot/prompts.py +218 -218
- ot/proxy/manager.py +14 -2
- ot/registry/__init__.py +189 -189
- ot/registry/parser.py +269 -269
- ot/server.py +330 -315
- ot/shortcuts/__init__.py +15 -15
- ot/shortcuts/aliases.py +87 -87
- ot/shortcuts/snippets.py +258 -258
- ot/stats/__init__.py +35 -35
- ot/stats/html.py +2 -2
- ot/stats/reader.py +354 -354
- ot/stats/timing.py +57 -57
- ot/support.py +63 -63
- ot/tools.py +1 -1
- ot/utils/batch.py +161 -161
- ot/utils/cache.py +120 -120
- ot/utils/exceptions.py +23 -23
- ot/utils/factory.py +178 -179
- ot/utils/format.py +65 -65
- ot/utils/http.py +202 -202
- ot/utils/platform.py +45 -45
- ot/utils/truncate.py +69 -69
- ot_tools/__init__.py +4 -4
- ot_tools/_convert/__init__.py +12 -12
- ot_tools/_convert/pdf.py +254 -254
- ot_tools/diagram.yaml +167 -167
- ot_tools/scaffold.py +2 -2
- ot_tools/transform.py +124 -19
- ot_tools/web_fetch.py +94 -43
- onetool_mcp-1.0.0b1.dist-info/METADATA +0 -163
- onetool_mcp-1.0.0b1.dist-info/RECORD +0 -132
- ot/config/defaults/bench.yaml +0 -4
- ot/config/defaults/onetool.yaml +0 -25
- ot/config/defaults/servers.yaml +0 -7
- ot/config/defaults/snippets.yaml +0 -4
- ot_tools/firecrawl.py +0 -732
- {onetool_mcp-1.0.0b1.dist-info → onetool_mcp-1.0.0rc2.dist-info}/WHEEL +0 -0
- {onetool_mcp-1.0.0b1.dist-info → onetool_mcp-1.0.0rc2.dist-info}/entry_points.txt +0 -0
- /ot/config/{defaults → global_templates}/tool_templates/extension.py +0 -0
- /ot/config/{defaults → global_templates}/tool_templates/isolated.py +0 -0
ot/shortcuts/snippets.py
CHANGED
|
@@ -1,258 +1,258 @@
|
|
|
1
|
-
"""Snippet parsing and expansion for OneTool shortcuts.
|
|
2
|
-
|
|
3
|
-
Handles snippet syntax parsing and Jinja2 template expansion:
|
|
4
|
-
- Single-line: $wsq q1=AI q2=ML p=Compare
|
|
5
|
-
- Multi-line: $wsq\nq1: AI\nq2: ML\np: Compare
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import re
|
|
11
|
-
from dataclasses import dataclass
|
|
12
|
-
from typing import TYPE_CHECKING, Any
|
|
13
|
-
|
|
14
|
-
from loguru import logger
|
|
15
|
-
|
|
16
|
-
if TYPE_CHECKING:
|
|
17
|
-
from ot.config import OneToolConfig, SnippetDef
|
|
18
|
-
|
|
19
|
-
try:
|
|
20
|
-
from jinja2 import Environment, StrictUndefined, TemplateSyntaxError
|
|
21
|
-
except ImportError as e:
|
|
22
|
-
raise ImportError(
|
|
23
|
-
"jinja2 is required for snippets. Install with: pip install jinja2"
|
|
24
|
-
) from e
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@dataclass
|
|
28
|
-
class ParsedSnippet:
|
|
29
|
-
"""Result of parsing a snippet invocation."""
|
|
30
|
-
|
|
31
|
-
name: str
|
|
32
|
-
params: dict[str, str]
|
|
33
|
-
raw: str
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def is_snippet(code: str) -> bool:
|
|
37
|
-
"""Check if code is a snippet invocation (starts with $).
|
|
38
|
-
|
|
39
|
-
Args:
|
|
40
|
-
code: Code to check
|
|
41
|
-
|
|
42
|
-
Returns:
|
|
43
|
-
True if code starts with $ (snippet syntax)
|
|
44
|
-
"""
|
|
45
|
-
stripped = code.strip()
|
|
46
|
-
# Must start with $ but not be $variable inside other code
|
|
47
|
-
return stripped.startswith("$") and not stripped.startswith("${")
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def parse_snippet(code: str) -> ParsedSnippet:
|
|
51
|
-
"""Parse a snippet invocation into name and parameters.
|
|
52
|
-
|
|
53
|
-
Supports two syntaxes:
|
|
54
|
-
- Single-line: $name key=value key2=value2
|
|
55
|
-
- Multi-line: $name\\nkey: value\\nkey2: value2
|
|
56
|
-
|
|
57
|
-
Args:
|
|
58
|
-
code: Snippet invocation string
|
|
59
|
-
|
|
60
|
-
Returns:
|
|
61
|
-
ParsedSnippet with name and extracted parameters
|
|
62
|
-
|
|
63
|
-
Raises:
|
|
64
|
-
ValueError: If snippet syntax is invalid
|
|
65
|
-
"""
|
|
66
|
-
stripped = code.strip()
|
|
67
|
-
|
|
68
|
-
if not stripped.startswith("$"):
|
|
69
|
-
raise ValueError(f"Snippet must start with $: {stripped[:50]}")
|
|
70
|
-
|
|
71
|
-
# Remove $ prefix
|
|
72
|
-
content = stripped[1:]
|
|
73
|
-
|
|
74
|
-
# Check for multi-line (has newline after snippet name)
|
|
75
|
-
lines = content.split("\n")
|
|
76
|
-
first_line = lines[0].strip()
|
|
77
|
-
|
|
78
|
-
# Extract snippet name (first word)
|
|
79
|
-
name_match = re.match(r"^(\w+)", first_line)
|
|
80
|
-
if not name_match:
|
|
81
|
-
raise ValueError(f"Invalid snippet name: {first_line[:50]}")
|
|
82
|
-
|
|
83
|
-
name = name_match.group(1)
|
|
84
|
-
|
|
85
|
-
# Check if multi-line or single-line
|
|
86
|
-
if len(lines) > 1:
|
|
87
|
-
return _parse_multiline_snippet(name, lines[1:], stripped)
|
|
88
|
-
else:
|
|
89
|
-
return _parse_singleline_snippet(name, first_line[len(name) :], stripped)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def _strip_quotes(value: str) -> str:
|
|
93
|
-
"""Strip matching outer quotes from a value.
|
|
94
|
-
|
|
95
|
-
Handles both single and double quotes. Only strips if quotes are balanced.
|
|
96
|
-
|
|
97
|
-
Args:
|
|
98
|
-
value: String that may have outer quotes
|
|
99
|
-
|
|
100
|
-
Returns:
|
|
101
|
-
String with outer quotes removed if present and balanced
|
|
102
|
-
"""
|
|
103
|
-
if len(value) >= 2 and (
|
|
104
|
-
(value.startswith('"') and value.endswith('"'))
|
|
105
|
-
or (value.startswith("'") and value.endswith("'"))
|
|
106
|
-
):
|
|
107
|
-
return value[1:-1]
|
|
108
|
-
return value
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _parse_singleline_snippet(name: str, params_str: str, raw: str) -> ParsedSnippet:
|
|
112
|
-
"""Parse single-line snippet parameters: key=value key2="value with spaces".
|
|
113
|
-
|
|
114
|
-
Values extend until the next key= or end of string.
|
|
115
|
-
Outer quotes are stripped from values (key="value" becomes key=value).
|
|
116
|
-
Escaped equals (\\=) are preserved in values.
|
|
117
|
-
"""
|
|
118
|
-
params: dict[str, str] = {}
|
|
119
|
-
params_str = params_str.strip()
|
|
120
|
-
|
|
121
|
-
if not params_str:
|
|
122
|
-
return ParsedSnippet(name=name, params=params, raw=raw)
|
|
123
|
-
|
|
124
|
-
# Replace escaped equals with placeholder
|
|
125
|
-
placeholder = "\x00EQUALS\x00"
|
|
126
|
-
params_str = params_str.replace("\\=", placeholder)
|
|
127
|
-
|
|
128
|
-
# Find all key=value pairs
|
|
129
|
-
# Pattern: word followed by = and then value until next word= or end
|
|
130
|
-
pattern = r"(\w+)=((?:[^=]|$)*?)(?=\s+\w+=|$)"
|
|
131
|
-
matches = re.findall(pattern, params_str)
|
|
132
|
-
|
|
133
|
-
for key, value in matches:
|
|
134
|
-
# Restore escaped equals and strip whitespace
|
|
135
|
-
value = value.replace(placeholder, "=").strip()
|
|
136
|
-
# Strip outer quotes from value (e.g., packages="react" -> packages=react)
|
|
137
|
-
value = _strip_quotes(value)
|
|
138
|
-
params[key] = value
|
|
139
|
-
|
|
140
|
-
return ParsedSnippet(name=name, params=params, raw=raw)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _parse_multiline_snippet(name: str, lines: list[str], raw: str) -> ParsedSnippet:
|
|
144
|
-
"""Parse multi-line snippet parameters: key: value.
|
|
145
|
-
|
|
146
|
-
Blank line terminates the snippet parameters.
|
|
147
|
-
Only the first colon is the separator (colons in values are preserved).
|
|
148
|
-
Outer quotes are stripped from values for consistency with single-line format.
|
|
149
|
-
"""
|
|
150
|
-
params: dict[str, str] = {}
|
|
151
|
-
|
|
152
|
-
for line in lines:
|
|
153
|
-
stripped = line.strip()
|
|
154
|
-
|
|
155
|
-
# Blank line terminates
|
|
156
|
-
if not stripped:
|
|
157
|
-
break
|
|
158
|
-
|
|
159
|
-
# Parse key: value (only first colon is separator)
|
|
160
|
-
colon_idx = stripped.find(":")
|
|
161
|
-
if colon_idx == -1:
|
|
162
|
-
logger.warning(f"Invalid snippet line (no colon): {stripped}")
|
|
163
|
-
continue
|
|
164
|
-
|
|
165
|
-
key = stripped[:colon_idx].strip()
|
|
166
|
-
value = stripped[colon_idx + 1 :].strip()
|
|
167
|
-
|
|
168
|
-
if not key:
|
|
169
|
-
logger.warning(f"Empty key in snippet line: {stripped}")
|
|
170
|
-
continue
|
|
171
|
-
|
|
172
|
-
# Strip outer quotes from value for consistency
|
|
173
|
-
value = _strip_quotes(value)
|
|
174
|
-
params[key] = value
|
|
175
|
-
|
|
176
|
-
return ParsedSnippet(name=name, params=params, raw=raw)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def expand_snippet(
|
|
180
|
-
parsed: ParsedSnippet,
|
|
181
|
-
config: OneToolConfig,
|
|
182
|
-
) -> str:
|
|
183
|
-
"""Expand a parsed snippet using Jinja2 templating.
|
|
184
|
-
|
|
185
|
-
Args:
|
|
186
|
-
parsed: Parsed snippet with name and parameters
|
|
187
|
-
config: Configuration with snippet definitions
|
|
188
|
-
|
|
189
|
-
Returns:
|
|
190
|
-
Expanded Python code from the snippet template
|
|
191
|
-
|
|
192
|
-
Raises:
|
|
193
|
-
ValueError: If snippet not found, missing required params, or Jinja2 error
|
|
194
|
-
"""
|
|
195
|
-
if parsed.name not in config.snippets:
|
|
196
|
-
available = ", ".join(sorted(config.snippets.keys())) or "(none)"
|
|
197
|
-
raise ValueError(f"Unknown snippet '{parsed.name}'. Available: {available}")
|
|
198
|
-
|
|
199
|
-
snippet_def: SnippetDef = config.snippets[parsed.name]
|
|
200
|
-
|
|
201
|
-
# Build context with defaults and provided values
|
|
202
|
-
context: dict[str, Any] = {}
|
|
203
|
-
|
|
204
|
-
# Apply defaults first
|
|
205
|
-
for param_name, param_def in snippet_def.params.items():
|
|
206
|
-
if param_def.default is not None:
|
|
207
|
-
context[param_name] = param_def.default
|
|
208
|
-
|
|
209
|
-
# Apply provided values
|
|
210
|
-
for key, value in parsed.params.items():
|
|
211
|
-
if key not in snippet_def.params:
|
|
212
|
-
logger.warning(
|
|
213
|
-
f"Unknown parameter '{key}' for snippet '{parsed.name}' (ignored)"
|
|
214
|
-
)
|
|
215
|
-
context[key] = value
|
|
216
|
-
|
|
217
|
-
# Check required parameters
|
|
218
|
-
for param_name, param_def in snippet_def.params.items():
|
|
219
|
-
if param_def.required and param_name not in context:
|
|
220
|
-
raise ValueError(
|
|
221
|
-
f"Snippet '{parsed.name}' requires parameter '{param_name}'"
|
|
222
|
-
)
|
|
223
|
-
|
|
224
|
-
# Render template with Jinja2
|
|
225
|
-
try:
|
|
226
|
-
env = Environment(undefined=StrictUndefined)
|
|
227
|
-
template = env.from_string(snippet_def.body)
|
|
228
|
-
return template.render(**context)
|
|
229
|
-
except TemplateSyntaxError as e:
|
|
230
|
-
raise ValueError(f"Jinja2 syntax error in snippet '{parsed.name}': {e}") from e
|
|
231
|
-
except Exception as e:
|
|
232
|
-
# StrictUndefined raises UndefinedError for missing variables
|
|
233
|
-
if "undefined" in str(e).lower():
|
|
234
|
-
raise ValueError(
|
|
235
|
-
f"Undefined variable in snippet '{parsed.name}': {e}"
|
|
236
|
-
) from e
|
|
237
|
-
raise ValueError(f"Error expanding snippet '{parsed.name}': {e}") from e
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def validate_snippets(config: OneToolConfig) -> list[str]:
|
|
241
|
-
"""Validate snippet definitions for Jinja2 syntax errors.
|
|
242
|
-
|
|
243
|
-
Args:
|
|
244
|
-
config: Configuration with snippet definitions
|
|
245
|
-
|
|
246
|
-
Returns:
|
|
247
|
-
List of validation errors (empty if valid)
|
|
248
|
-
"""
|
|
249
|
-
errors: list[str] = []
|
|
250
|
-
env = Environment(undefined=StrictUndefined)
|
|
251
|
-
|
|
252
|
-
for name, snippet_def in config.snippets.items():
|
|
253
|
-
try:
|
|
254
|
-
env.from_string(snippet_def.body)
|
|
255
|
-
except TemplateSyntaxError as e:
|
|
256
|
-
errors.append(f"Snippet '{name}' has invalid Jinja2 syntax: {e}")
|
|
257
|
-
|
|
258
|
-
return errors
|
|
1
|
+
"""Snippet parsing and expansion for OneTool shortcuts.
|
|
2
|
+
|
|
3
|
+
Handles snippet syntax parsing and Jinja2 template expansion:
|
|
4
|
+
- Single-line: $wsq q1=AI q2=ML p=Compare
|
|
5
|
+
- Multi-line: $wsq\nq1: AI\nq2: ML\np: Compare
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ot.config import OneToolConfig, SnippetDef
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from jinja2 import Environment, StrictUndefined, TemplateSyntaxError
|
|
21
|
+
except ImportError as e:
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"jinja2 is required for snippets. Install with: pip install jinja2"
|
|
24
|
+
) from e
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ParsedSnippet:
|
|
29
|
+
"""Result of parsing a snippet invocation."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
params: dict[str, str]
|
|
33
|
+
raw: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_snippet(code: str) -> bool:
|
|
37
|
+
"""Check if code is a snippet invocation (starts with $).
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
code: Code to check
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
True if code starts with $ (snippet syntax)
|
|
44
|
+
"""
|
|
45
|
+
stripped = code.strip()
|
|
46
|
+
# Must start with $ but not be $variable inside other code
|
|
47
|
+
return stripped.startswith("$") and not stripped.startswith("${")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_snippet(code: str) -> ParsedSnippet:
|
|
51
|
+
"""Parse a snippet invocation into name and parameters.
|
|
52
|
+
|
|
53
|
+
Supports two syntaxes:
|
|
54
|
+
- Single-line: $name key=value key2=value2
|
|
55
|
+
- Multi-line: $name\\nkey: value\\nkey2: value2
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
code: Snippet invocation string
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
ParsedSnippet with name and extracted parameters
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If snippet syntax is invalid
|
|
65
|
+
"""
|
|
66
|
+
stripped = code.strip()
|
|
67
|
+
|
|
68
|
+
if not stripped.startswith("$"):
|
|
69
|
+
raise ValueError(f"Snippet must start with $: {stripped[:50]}")
|
|
70
|
+
|
|
71
|
+
# Remove $ prefix
|
|
72
|
+
content = stripped[1:]
|
|
73
|
+
|
|
74
|
+
# Check for multi-line (has newline after snippet name)
|
|
75
|
+
lines = content.split("\n")
|
|
76
|
+
first_line = lines[0].strip()
|
|
77
|
+
|
|
78
|
+
# Extract snippet name (first word)
|
|
79
|
+
name_match = re.match(r"^(\w+)", first_line)
|
|
80
|
+
if not name_match:
|
|
81
|
+
raise ValueError(f"Invalid snippet name: {first_line[:50]}")
|
|
82
|
+
|
|
83
|
+
name = name_match.group(1)
|
|
84
|
+
|
|
85
|
+
# Check if multi-line or single-line
|
|
86
|
+
if len(lines) > 1:
|
|
87
|
+
return _parse_multiline_snippet(name, lines[1:], stripped)
|
|
88
|
+
else:
|
|
89
|
+
return _parse_singleline_snippet(name, first_line[len(name) :], stripped)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _strip_quotes(value: str) -> str:
|
|
93
|
+
"""Strip matching outer quotes from a value.
|
|
94
|
+
|
|
95
|
+
Handles both single and double quotes. Only strips if quotes are balanced.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
value: String that may have outer quotes
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
String with outer quotes removed if present and balanced
|
|
102
|
+
"""
|
|
103
|
+
if len(value) >= 2 and (
|
|
104
|
+
(value.startswith('"') and value.endswith('"'))
|
|
105
|
+
or (value.startswith("'") and value.endswith("'"))
|
|
106
|
+
):
|
|
107
|
+
return value[1:-1]
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_singleline_snippet(name: str, params_str: str, raw: str) -> ParsedSnippet:
|
|
112
|
+
"""Parse single-line snippet parameters: key=value key2="value with spaces".
|
|
113
|
+
|
|
114
|
+
Values extend until the next key= or end of string.
|
|
115
|
+
Outer quotes are stripped from values (key="value" becomes key=value).
|
|
116
|
+
Escaped equals (\\=) are preserved in values.
|
|
117
|
+
"""
|
|
118
|
+
params: dict[str, str] = {}
|
|
119
|
+
params_str = params_str.strip()
|
|
120
|
+
|
|
121
|
+
if not params_str:
|
|
122
|
+
return ParsedSnippet(name=name, params=params, raw=raw)
|
|
123
|
+
|
|
124
|
+
# Replace escaped equals with placeholder
|
|
125
|
+
placeholder = "\x00EQUALS\x00"
|
|
126
|
+
params_str = params_str.replace("\\=", placeholder)
|
|
127
|
+
|
|
128
|
+
# Find all key=value pairs
|
|
129
|
+
# Pattern: word followed by = and then value until next word= or end
|
|
130
|
+
pattern = r"(\w+)=((?:[^=]|$)*?)(?=\s+\w+=|$)"
|
|
131
|
+
matches = re.findall(pattern, params_str)
|
|
132
|
+
|
|
133
|
+
for key, value in matches:
|
|
134
|
+
# Restore escaped equals and strip whitespace
|
|
135
|
+
value = value.replace(placeholder, "=").strip()
|
|
136
|
+
# Strip outer quotes from value (e.g., packages="react" -> packages=react)
|
|
137
|
+
value = _strip_quotes(value)
|
|
138
|
+
params[key] = value
|
|
139
|
+
|
|
140
|
+
return ParsedSnippet(name=name, params=params, raw=raw)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_multiline_snippet(name: str, lines: list[str], raw: str) -> ParsedSnippet:
|
|
144
|
+
"""Parse multi-line snippet parameters: key: value.
|
|
145
|
+
|
|
146
|
+
Blank line terminates the snippet parameters.
|
|
147
|
+
Only the first colon is the separator (colons in values are preserved).
|
|
148
|
+
Outer quotes are stripped from values for consistency with single-line format.
|
|
149
|
+
"""
|
|
150
|
+
params: dict[str, str] = {}
|
|
151
|
+
|
|
152
|
+
for line in lines:
|
|
153
|
+
stripped = line.strip()
|
|
154
|
+
|
|
155
|
+
# Blank line terminates
|
|
156
|
+
if not stripped:
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
# Parse key: value (only first colon is separator)
|
|
160
|
+
colon_idx = stripped.find(":")
|
|
161
|
+
if colon_idx == -1:
|
|
162
|
+
logger.warning(f"Invalid snippet line (no colon): {stripped}")
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
key = stripped[:colon_idx].strip()
|
|
166
|
+
value = stripped[colon_idx + 1 :].strip()
|
|
167
|
+
|
|
168
|
+
if not key:
|
|
169
|
+
logger.warning(f"Empty key in snippet line: {stripped}")
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
# Strip outer quotes from value for consistency
|
|
173
|
+
value = _strip_quotes(value)
|
|
174
|
+
params[key] = value
|
|
175
|
+
|
|
176
|
+
return ParsedSnippet(name=name, params=params, raw=raw)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def expand_snippet(
|
|
180
|
+
parsed: ParsedSnippet,
|
|
181
|
+
config: OneToolConfig,
|
|
182
|
+
) -> str:
|
|
183
|
+
"""Expand a parsed snippet using Jinja2 templating.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
parsed: Parsed snippet with name and parameters
|
|
187
|
+
config: Configuration with snippet definitions
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Expanded Python code from the snippet template
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
ValueError: If snippet not found, missing required params, or Jinja2 error
|
|
194
|
+
"""
|
|
195
|
+
if parsed.name not in config.snippets:
|
|
196
|
+
available = ", ".join(sorted(config.snippets.keys())) or "(none)"
|
|
197
|
+
raise ValueError(f"Unknown snippet '{parsed.name}'. Available: {available}")
|
|
198
|
+
|
|
199
|
+
snippet_def: SnippetDef = config.snippets[parsed.name]
|
|
200
|
+
|
|
201
|
+
# Build context with defaults and provided values
|
|
202
|
+
context: dict[str, Any] = {}
|
|
203
|
+
|
|
204
|
+
# Apply defaults first
|
|
205
|
+
for param_name, param_def in snippet_def.params.items():
|
|
206
|
+
if param_def.default is not None:
|
|
207
|
+
context[param_name] = param_def.default
|
|
208
|
+
|
|
209
|
+
# Apply provided values
|
|
210
|
+
for key, value in parsed.params.items():
|
|
211
|
+
if key not in snippet_def.params:
|
|
212
|
+
logger.warning(
|
|
213
|
+
f"Unknown parameter '{key}' for snippet '{parsed.name}' (ignored)"
|
|
214
|
+
)
|
|
215
|
+
context[key] = value
|
|
216
|
+
|
|
217
|
+
# Check required parameters
|
|
218
|
+
for param_name, param_def in snippet_def.params.items():
|
|
219
|
+
if param_def.required and param_name not in context:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"Snippet '{parsed.name}' requires parameter '{param_name}'"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Render template with Jinja2
|
|
225
|
+
try:
|
|
226
|
+
env = Environment(undefined=StrictUndefined)
|
|
227
|
+
template = env.from_string(snippet_def.body)
|
|
228
|
+
return template.render(**context)
|
|
229
|
+
except TemplateSyntaxError as e:
|
|
230
|
+
raise ValueError(f"Jinja2 syntax error in snippet '{parsed.name}': {e}") from e
|
|
231
|
+
except Exception as e:
|
|
232
|
+
# StrictUndefined raises UndefinedError for missing variables
|
|
233
|
+
if "undefined" in str(e).lower():
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"Undefined variable in snippet '{parsed.name}': {e}"
|
|
236
|
+
) from e
|
|
237
|
+
raise ValueError(f"Error expanding snippet '{parsed.name}': {e}") from e
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def validate_snippets(config: OneToolConfig) -> list[str]:
|
|
241
|
+
"""Validate snippet definitions for Jinja2 syntax errors.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
config: Configuration with snippet definitions
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of validation errors (empty if valid)
|
|
248
|
+
"""
|
|
249
|
+
errors: list[str] = []
|
|
250
|
+
env = Environment(undefined=StrictUndefined)
|
|
251
|
+
|
|
252
|
+
for name, snippet_def in config.snippets.items():
|
|
253
|
+
try:
|
|
254
|
+
env.from_string(snippet_def.body)
|
|
255
|
+
except TemplateSyntaxError as e:
|
|
256
|
+
errors.append(f"Snippet '{name}' has invalid Jinja2 syntax: {e}")
|
|
257
|
+
|
|
258
|
+
return errors
|
ot/stats/__init__.py
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
"""Runtime statistics collection for OneTool.
|
|
2
|
-
|
|
3
|
-
Two-level statistics:
|
|
4
|
-
- Run-level: Tracks run() calls, durations, and calculates context savings estimates.
|
|
5
|
-
- Tool-level: Tracks actual tool invocations at the executor dispatch level.
|
|
6
|
-
|
|
7
|
-
Records are stored in a single JSONL file with a 'type' field discriminator.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from ot.stats.html import generate_html_report
|
|
11
|
-
from ot.stats.jsonl_writer import (
|
|
12
|
-
JsonlStatsWriter,
|
|
13
|
-
get_client_name,
|
|
14
|
-
get_stats_writer,
|
|
15
|
-
record_tool_stats,
|
|
16
|
-
set_client_name,
|
|
17
|
-
set_stats_writer,
|
|
18
|
-
)
|
|
19
|
-
from ot.stats.reader import AggregatedStats, Period, StatsReader, ToolStats
|
|
20
|
-
from ot.stats.timing import timed_tool_call
|
|
21
|
-
|
|
22
|
-
__all__ = [
|
|
23
|
-
"AggregatedStats",
|
|
24
|
-
"JsonlStatsWriter",
|
|
25
|
-
"Period",
|
|
26
|
-
"StatsReader",
|
|
27
|
-
"ToolStats",
|
|
28
|
-
"generate_html_report",
|
|
29
|
-
"get_client_name",
|
|
30
|
-
"get_stats_writer",
|
|
31
|
-
"record_tool_stats",
|
|
32
|
-
"set_client_name",
|
|
33
|
-
"set_stats_writer",
|
|
34
|
-
"timed_tool_call",
|
|
35
|
-
]
|
|
1
|
+
"""Runtime statistics collection for OneTool.
|
|
2
|
+
|
|
3
|
+
Two-level statistics:
|
|
4
|
+
- Run-level: Tracks run() calls, durations, and calculates context savings estimates.
|
|
5
|
+
- Tool-level: Tracks actual tool invocations at the executor dispatch level.
|
|
6
|
+
|
|
7
|
+
Records are stored in a single JSONL file with a 'type' field discriminator.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ot.stats.html import generate_html_report
|
|
11
|
+
from ot.stats.jsonl_writer import (
|
|
12
|
+
JsonlStatsWriter,
|
|
13
|
+
get_client_name,
|
|
14
|
+
get_stats_writer,
|
|
15
|
+
record_tool_stats,
|
|
16
|
+
set_client_name,
|
|
17
|
+
set_stats_writer,
|
|
18
|
+
)
|
|
19
|
+
from ot.stats.reader import AggregatedStats, Period, StatsReader, ToolStats
|
|
20
|
+
from ot.stats.timing import timed_tool_call
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AggregatedStats",
|
|
24
|
+
"JsonlStatsWriter",
|
|
25
|
+
"Period",
|
|
26
|
+
"StatsReader",
|
|
27
|
+
"ToolStats",
|
|
28
|
+
"generate_html_report",
|
|
29
|
+
"get_client_name",
|
|
30
|
+
"get_stats_writer",
|
|
31
|
+
"record_tool_stats",
|
|
32
|
+
"set_client_name",
|
|
33
|
+
"set_stats_writer",
|
|
34
|
+
"timed_tool_call",
|
|
35
|
+
]
|
ot/stats/html.py
CHANGED
|
@@ -15,7 +15,7 @@ from ot.support import (
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
if TYPE_CHECKING:
|
|
18
|
-
from ot.stats.reader import AggregatedStats
|
|
18
|
+
from ot.stats.reader import AggregatedStats, ToolStats
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def generate_html_report(stats: AggregatedStats) -> str:
|
|
@@ -61,7 +61,7 @@ def generate_html_report(stats: AggregatedStats) -> str:
|
|
|
61
61
|
# Group tools by pack (prefix before the dot)
|
|
62
62
|
from collections import defaultdict
|
|
63
63
|
|
|
64
|
-
packs: dict[str, list] = defaultdict(list)
|
|
64
|
+
packs: dict[str, list[ToolStats]] = defaultdict(list)
|
|
65
65
|
for tool in stats.tools:
|
|
66
66
|
pack_name = tool.tool.split(".")[0] if "." in tool.tool else "other"
|
|
67
67
|
packs[pack_name].append(tool)
|