ostruct-cli 0.8.29__py3-none-any.whl → 1.0.1__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.
- ostruct/cli/__init__.py +3 -15
- ostruct/cli/attachment_processor.py +455 -0
- ostruct/cli/attachment_template_bridge.py +973 -0
- ostruct/cli/cli.py +157 -33
- ostruct/cli/click_options.py +775 -692
- ostruct/cli/code_interpreter.py +195 -12
- ostruct/cli/commands/__init__.py +0 -3
- ostruct/cli/commands/run.py +289 -62
- ostruct/cli/config.py +23 -22
- ostruct/cli/constants.py +89 -0
- ostruct/cli/errors.py +175 -5
- ostruct/cli/explicit_file_processor.py +0 -15
- ostruct/cli/file_info.py +97 -15
- ostruct/cli/file_list.py +43 -1
- ostruct/cli/file_search.py +68 -2
- ostruct/cli/help_json.py +235 -0
- ostruct/cli/mcp_integration.py +13 -16
- ostruct/cli/params.py +217 -0
- ostruct/cli/plan_assembly.py +335 -0
- ostruct/cli/plan_printing.py +385 -0
- ostruct/cli/progress_reporting.py +8 -56
- ostruct/cli/quick_ref_help.py +128 -0
- ostruct/cli/rich_config.py +299 -0
- ostruct/cli/runner.py +397 -190
- ostruct/cli/security/__init__.py +2 -0
- ostruct/cli/security/allowed_checker.py +41 -0
- ostruct/cli/security/normalization.py +13 -9
- ostruct/cli/security/security_manager.py +558 -17
- ostruct/cli/security/types.py +15 -0
- ostruct/cli/template_debug.py +283 -261
- ostruct/cli/template_debug_help.py +233 -142
- ostruct/cli/template_env.py +46 -5
- ostruct/cli/template_filters.py +415 -8
- ostruct/cli/template_processor.py +240 -619
- ostruct/cli/template_rendering.py +49 -73
- ostruct/cli/template_validation.py +2 -1
- ostruct/cli/token_validation.py +35 -15
- ostruct/cli/types.py +15 -19
- ostruct/cli/unicode_compat.py +283 -0
- ostruct/cli/upload_manager.py +448 -0
- ostruct/cli/validators.py +255 -54
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/METADATA +231 -128
- ostruct_cli-1.0.1.dist-info/RECORD +80 -0
- ostruct/cli/commands/quick_ref.py +0 -54
- ostruct/cli/template_optimizer.py +0 -478
- ostruct_cli-0.8.29.dist-info/RECORD +0 -71
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/entry_points.txt +0 -0
ostruct/cli/file_list.py
CHANGED
@@ -30,6 +30,9 @@ class FileInfoList(List[FileInfo]):
|
|
30
30
|
handling of multi-file scenarios through indexing (files[0].content) or the
|
31
31
|
|single filter (files|single.content).
|
32
32
|
|
33
|
+
Implements the file-sequence protocol by being iterable (already inherits from list)
|
34
|
+
and providing a .first property for uniform access patterns.
|
35
|
+
|
33
36
|
This class is thread-safe. All operations that access or modify the internal list
|
34
37
|
are protected by a reentrant lock (RLock). This allows nested method calls while
|
35
38
|
holding the lock, preventing deadlocks in cases like:
|
@@ -49,6 +52,12 @@ class FileInfoList(List[FileInfo]):
|
|
49
52
|
content = files[0].content # Access first file explicitly
|
50
53
|
content = files|single.content # Use |single filter for validation
|
51
54
|
|
55
|
+
Uniform iteration (file-sequence protocol):
|
56
|
+
for file in files: # Works for both single and multiple files
|
57
|
+
print(file.content)
|
58
|
+
|
59
|
+
first_file = files.first # Get first file uniformly
|
60
|
+
|
52
61
|
Properties:
|
53
62
|
content: File content - only for single file from file mapping (not directory)
|
54
63
|
path: File path - only for single file from file mapping
|
@@ -56,6 +65,8 @@ class FileInfoList(List[FileInfo]):
|
|
56
65
|
size: File size in bytes - only for single file from file mapping
|
57
66
|
name: Filename without directory path - only for single file from file mapping
|
58
67
|
names: Always returns list of all filenames (safe for multi-file access)
|
68
|
+
first: Returns the first FileInfo object (uniform access)
|
69
|
+
is_collection: Always returns True (indicates this is a collection)
|
59
70
|
|
60
71
|
Raises:
|
61
72
|
ValueError: When accessing scalar properties on empty list, multiple files, or directory mappings
|
@@ -85,6 +96,37 @@ class FileInfoList(List[FileInfo]):
|
|
85
96
|
self._from_dir = from_dir
|
86
97
|
self._var_alias = var_alias
|
87
98
|
|
99
|
+
@property
|
100
|
+
def first(self) -> FileInfo:
|
101
|
+
"""Get the first file in the collection.
|
102
|
+
|
103
|
+
This provides a uniform interface with FileInfo.first,
|
104
|
+
allowing templates to use .first regardless of whether they're
|
105
|
+
dealing with a single file or a collection.
|
106
|
+
|
107
|
+
Returns:
|
108
|
+
The first FileInfo object in the list
|
109
|
+
|
110
|
+
Raises:
|
111
|
+
ValueError: If the list is empty
|
112
|
+
"""
|
113
|
+
with self._lock:
|
114
|
+
if not self:
|
115
|
+
var_name = self._var_alias or "file_list"
|
116
|
+
raise ValueError(
|
117
|
+
f"No files in '{var_name}'. Cannot access .first property."
|
118
|
+
)
|
119
|
+
return self[0]
|
120
|
+
|
121
|
+
@property
|
122
|
+
def is_collection(self) -> bool:
|
123
|
+
"""Indicate whether this is a collection of files.
|
124
|
+
|
125
|
+
Returns:
|
126
|
+
True, since FileInfoList represents a collection of files
|
127
|
+
"""
|
128
|
+
return True
|
129
|
+
|
88
130
|
@property
|
89
131
|
def content(self) -> str:
|
90
132
|
"""Get the content of a single file.
|
@@ -443,7 +485,7 @@ class FileInfoList(List[FileInfo]):
|
|
443
485
|
if not self:
|
444
486
|
return "FileInfoList([])"
|
445
487
|
|
446
|
-
# For single file from file mapping (--
|
488
|
+
# For single file from file mapping (--file alias, etc.)
|
447
489
|
if len(self) == 1 and not self._from_dir:
|
448
490
|
var_name = self._var_alias or "file_var"
|
449
491
|
return f"[File '{self[0].path}' - Use {{ {var_name}.content }} to access file content]"
|
ostruct/cli/file_search.py
CHANGED
@@ -10,25 +10,34 @@ import logging
|
|
10
10
|
import os
|
11
11
|
import time
|
12
12
|
from pathlib import Path
|
13
|
-
from typing import Any, Dict, List
|
13
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
14
14
|
|
15
15
|
from openai import AsyncOpenAI
|
16
16
|
|
17
|
+
if TYPE_CHECKING:
|
18
|
+
from .upload_manager import SharedUploadManager
|
19
|
+
|
17
20
|
logger = logging.getLogger(__name__)
|
18
21
|
|
19
22
|
|
20
23
|
class FileSearchManager:
|
21
24
|
"""Manager for File Search vector store operations with retry logic."""
|
22
25
|
|
23
|
-
def __init__(
|
26
|
+
def __init__(
|
27
|
+
self,
|
28
|
+
client: AsyncOpenAI,
|
29
|
+
upload_manager: Optional["SharedUploadManager"] = None,
|
30
|
+
) -> None:
|
24
31
|
"""Initialize File Search manager.
|
25
32
|
|
26
33
|
Args:
|
27
34
|
client: AsyncOpenAI client instance
|
35
|
+
upload_manager: Optional shared upload manager for deduplication
|
28
36
|
"""
|
29
37
|
self.client = client
|
30
38
|
self.uploaded_file_ids: List[str] = []
|
31
39
|
self.created_vector_stores: List[str] = []
|
40
|
+
self.upload_manager = upload_manager
|
32
41
|
|
33
42
|
async def create_vector_store_with_retry(
|
34
43
|
self,
|
@@ -321,6 +330,63 @@ class FileSearchManager:
|
|
321
330
|
"vector_store_ids": [vector_store_id],
|
322
331
|
}
|
323
332
|
|
333
|
+
async def create_vector_store_from_shared_manager(
|
334
|
+
self,
|
335
|
+
vector_store_name: str = "ostruct_vector_store",
|
336
|
+
max_retries: int = 3,
|
337
|
+
retry_delay: float = 1.0,
|
338
|
+
) -> str:
|
339
|
+
"""Create vector store and populate with files from shared upload manager.
|
340
|
+
|
341
|
+
Args:
|
342
|
+
vector_store_name: Name for the vector store
|
343
|
+
max_retries: Maximum retry attempts
|
344
|
+
retry_delay: Delay between retries
|
345
|
+
|
346
|
+
Returns:
|
347
|
+
Vector store ID
|
348
|
+
|
349
|
+
Raises:
|
350
|
+
Exception: If vector store creation or file upload fails
|
351
|
+
"""
|
352
|
+
if not self.upload_manager:
|
353
|
+
logger.warning("No shared upload manager available")
|
354
|
+
# Fall back to creating empty vector store
|
355
|
+
return await self.create_vector_store_with_retry(
|
356
|
+
vector_store_name, max_retries, retry_delay
|
357
|
+
)
|
358
|
+
|
359
|
+
# Get file IDs from shared manager
|
360
|
+
await self.upload_manager.upload_for_tool("file-search")
|
361
|
+
file_ids = self.upload_manager.get_files_for_tool("file-search")
|
362
|
+
|
363
|
+
if not file_ids:
|
364
|
+
logger.debug(
|
365
|
+
"No files for file-search, creating empty vector store"
|
366
|
+
)
|
367
|
+
return await self.create_vector_store_with_retry(
|
368
|
+
vector_store_name, max_retries, retry_delay
|
369
|
+
)
|
370
|
+
|
371
|
+
# Create vector store
|
372
|
+
vector_store_id = await self.create_vector_store_with_retry(
|
373
|
+
vector_store_name, max_retries, retry_delay
|
374
|
+
)
|
375
|
+
|
376
|
+
# Add files to vector store
|
377
|
+
await self._add_files_to_vector_store_with_retry(
|
378
|
+
vector_store_id, file_ids, max_retries, retry_delay
|
379
|
+
)
|
380
|
+
|
381
|
+
# Track uploaded files for cleanup
|
382
|
+
self.uploaded_file_ids.extend(file_ids)
|
383
|
+
|
384
|
+
logger.debug(
|
385
|
+
f"Created vector store {vector_store_id} with {len(file_ids)} files from shared manager"
|
386
|
+
)
|
387
|
+
|
388
|
+
return vector_store_id
|
389
|
+
|
324
390
|
async def cleanup_resources(self) -> None:
|
325
391
|
"""Clean up uploaded files and created vector stores.
|
326
392
|
|
ostruct/cli/help_json.py
ADDED
@@ -0,0 +1,235 @@
|
|
1
|
+
"""Unified JSON help system for ostruct CLI."""
|
2
|
+
|
3
|
+
import json
|
4
|
+
from typing import Any, Dict, List
|
5
|
+
|
6
|
+
import click
|
7
|
+
|
8
|
+
from .. import __version__
|
9
|
+
|
10
|
+
|
11
|
+
def generate_attachment_system_info() -> Dict[str, Any]:
|
12
|
+
"""Generate structured attachment system information from codebase constants."""
|
13
|
+
from .params import TARGET_NORMALISE
|
14
|
+
|
15
|
+
# Build structured target information
|
16
|
+
canonical_targets = set(TARGET_NORMALISE.values())
|
17
|
+
aliases: Dict[str, List[str]] = {}
|
18
|
+
|
19
|
+
for alias, canonical in TARGET_NORMALISE.items():
|
20
|
+
if alias != canonical: # Only include actual aliases
|
21
|
+
if canonical not in aliases:
|
22
|
+
aliases[canonical] = []
|
23
|
+
aliases[canonical].append(alias)
|
24
|
+
|
25
|
+
targets = {}
|
26
|
+
for canonical in sorted(canonical_targets):
|
27
|
+
targets[canonical] = {
|
28
|
+
"canonical_name": canonical,
|
29
|
+
"aliases": sorted(aliases.get(canonical, [])),
|
30
|
+
"description": _get_target_description(canonical),
|
31
|
+
"type": "file_routing_target",
|
32
|
+
}
|
33
|
+
|
34
|
+
return {
|
35
|
+
"format_spec": "[targets:]alias path",
|
36
|
+
"targets": targets,
|
37
|
+
"examples": [
|
38
|
+
{
|
39
|
+
"syntax": "--file data file.txt",
|
40
|
+
"targets": ["prompt"],
|
41
|
+
"description": "Template access only (default target)",
|
42
|
+
},
|
43
|
+
{
|
44
|
+
"syntax": "--file ci:analysis data.csv",
|
45
|
+
"targets": ["code-interpreter"],
|
46
|
+
"description": "Code execution & analysis",
|
47
|
+
},
|
48
|
+
{
|
49
|
+
"syntax": "--dir fs:docs ./documentation",
|
50
|
+
"targets": ["file-search"],
|
51
|
+
"description": "Document search & retrieval",
|
52
|
+
},
|
53
|
+
{
|
54
|
+
"syntax": "--file ci,fs:shared data.json",
|
55
|
+
"targets": ["code-interpreter", "file-search"],
|
56
|
+
"description": "Multi-target routing",
|
57
|
+
},
|
58
|
+
],
|
59
|
+
}
|
60
|
+
|
61
|
+
|
62
|
+
def _get_target_description(target: str) -> str:
|
63
|
+
"""Get description for a target."""
|
64
|
+
descriptions = {
|
65
|
+
"prompt": "Template access only (default)",
|
66
|
+
"code-interpreter": "Code execution & analysis",
|
67
|
+
"file-search": "Document search & retrieval",
|
68
|
+
}
|
69
|
+
return descriptions.get(target, f"Unknown target: {target}")
|
70
|
+
|
71
|
+
|
72
|
+
def generate_json_output_modes() -> Dict[str, Any]:
|
73
|
+
"""Generate structured JSON output mode information."""
|
74
|
+
return {
|
75
|
+
"help_json": {
|
76
|
+
"description": "Output command help in JSON format",
|
77
|
+
"output_destination": "stdout",
|
78
|
+
"exit_behavior": "exits_after_output",
|
79
|
+
"scope": "single_command_or_full_cli",
|
80
|
+
},
|
81
|
+
"dry_run_json": {
|
82
|
+
"description": "Output execution plan as JSON with --dry-run",
|
83
|
+
"output_destination": "stdout",
|
84
|
+
"requires": ["--dry-run"],
|
85
|
+
"exit_behavior": "exits_after_output",
|
86
|
+
"scope": "execution_plan",
|
87
|
+
},
|
88
|
+
"run_summary_json": {
|
89
|
+
"description": "Output run summary as JSON to stderr after execution",
|
90
|
+
"output_destination": "stderr",
|
91
|
+
"requires": [],
|
92
|
+
"conflicts_with": ["--dry-run"],
|
93
|
+
"exit_behavior": "continues_execution",
|
94
|
+
"scope": "execution_summary",
|
95
|
+
},
|
96
|
+
}
|
97
|
+
|
98
|
+
|
99
|
+
def enhance_param_info(
|
100
|
+
param_info: Dict[str, Any], param: click.Parameter
|
101
|
+
) -> Dict[str, Any]:
|
102
|
+
"""Enhance parameter info with dynamic data."""
|
103
|
+
# Import here to avoid circular imports
|
104
|
+
try:
|
105
|
+
from .click_options import ModelChoice
|
106
|
+
|
107
|
+
model_choice_class = ModelChoice
|
108
|
+
except ImportError:
|
109
|
+
model_choice_class = None
|
110
|
+
|
111
|
+
# For model parameter, add dynamic choices metadata
|
112
|
+
if (
|
113
|
+
param.name == "model"
|
114
|
+
and model_choice_class is not None
|
115
|
+
and isinstance(param.type, model_choice_class)
|
116
|
+
):
|
117
|
+
param_info["dynamic_choices"] = True
|
118
|
+
param_info["choices_source"] = "openai_model_registry"
|
119
|
+
|
120
|
+
# Add registry metadata if available
|
121
|
+
try:
|
122
|
+
from openai_model_registry import ModelRegistry
|
123
|
+
|
124
|
+
registry = ModelRegistry.get_instance()
|
125
|
+
choices_list = list(param.type.choices)
|
126
|
+
param_info["registry_metadata"] = {
|
127
|
+
"total_models": len(list(registry.models)),
|
128
|
+
"structured_output_models": len(choices_list),
|
129
|
+
"registry_path": str(
|
130
|
+
getattr(registry.config, "registry_path", "unknown")
|
131
|
+
),
|
132
|
+
}
|
133
|
+
except Exception:
|
134
|
+
param_info["registry_metadata"] = {"status": "unavailable"}
|
135
|
+
|
136
|
+
return param_info
|
137
|
+
|
138
|
+
|
139
|
+
def generate_usage_patterns_from_commands(
|
140
|
+
commands: Dict[str, Any],
|
141
|
+
) -> Dict[str, str]:
|
142
|
+
"""Generate usage patterns from actual command definitions instead of hardcoding."""
|
143
|
+
# This would ideally inspect the actual commands and generate examples
|
144
|
+
# For now, we'll keep the patterns but mark them as generated
|
145
|
+
patterns = {}
|
146
|
+
|
147
|
+
if "run" in commands:
|
148
|
+
patterns.update(
|
149
|
+
{
|
150
|
+
"basic_template": "ostruct run TEMPLATE.j2 SCHEMA.json -V name=value",
|
151
|
+
"file_attachment": "ostruct run TEMPLATE.j2 SCHEMA.json --file ci:data DATA.csv --file fs:docs DOCS.pdf",
|
152
|
+
"mcp_integration": "ostruct run TEMPLATE.j2 SCHEMA.json --mcp-server label@https://server.com/sse",
|
153
|
+
"dry_run": "ostruct run TEMPLATE.j2 SCHEMA.json --dry-run",
|
154
|
+
"json_output": "ostruct run TEMPLATE.j2 SCHEMA.json --dry-run-json",
|
155
|
+
}
|
156
|
+
)
|
157
|
+
|
158
|
+
return patterns
|
159
|
+
|
160
|
+
|
161
|
+
def print_command_help_json(
|
162
|
+
ctx: click.Context, param: click.Parameter, value: Any
|
163
|
+
) -> None:
|
164
|
+
"""Print single command help in JSON format."""
|
165
|
+
if not value or ctx.resilient_parsing:
|
166
|
+
return
|
167
|
+
|
168
|
+
# Use Click's built-in to_info_dict() method
|
169
|
+
help_data = ctx.to_info_dict() # type: ignore[attr-defined]
|
170
|
+
|
171
|
+
# Enhance parameter info with dynamic data
|
172
|
+
if "command" in help_data and "params" in help_data["command"]:
|
173
|
+
for param_info in help_data["command"]["params"]:
|
174
|
+
# Find the corresponding Click parameter
|
175
|
+
param_name = param_info.get("name")
|
176
|
+
if param_name:
|
177
|
+
for click_param in ctx.command.params:
|
178
|
+
if click_param.name == param_name:
|
179
|
+
enhance_param_info(param_info, click_param)
|
180
|
+
break
|
181
|
+
|
182
|
+
# Add ostruct-specific metadata
|
183
|
+
help_data.update(
|
184
|
+
{
|
185
|
+
"ostruct_version": __version__,
|
186
|
+
"help_type": "single_command",
|
187
|
+
"attachment_system": generate_attachment_system_info(),
|
188
|
+
"json_output_modes": generate_json_output_modes(),
|
189
|
+
}
|
190
|
+
)
|
191
|
+
|
192
|
+
click.echo(json.dumps(help_data, indent=2))
|
193
|
+
ctx.exit(0)
|
194
|
+
|
195
|
+
|
196
|
+
def print_full_cli_help_json(
|
197
|
+
ctx: click.Context, param: click.Parameter, value: Any
|
198
|
+
) -> None:
|
199
|
+
"""Print comprehensive help for all commands in JSON format."""
|
200
|
+
if not value or ctx.resilient_parsing:
|
201
|
+
return
|
202
|
+
|
203
|
+
# Get main group help
|
204
|
+
main_help = ctx.to_info_dict() # type: ignore[attr-defined]
|
205
|
+
|
206
|
+
# Get all commands help
|
207
|
+
commands_help = {}
|
208
|
+
if hasattr(ctx.command, "commands"):
|
209
|
+
for cmd_name, cmd in ctx.command.commands.items():
|
210
|
+
try:
|
211
|
+
cmd_ctx = cmd.make_context(
|
212
|
+
cmd_name, [], parent=ctx, resilient_parsing=True
|
213
|
+
)
|
214
|
+
commands_help[cmd_name] = cmd_ctx.to_info_dict()
|
215
|
+
except Exception as e:
|
216
|
+
commands_help[cmd_name] = {
|
217
|
+
"name": cmd_name,
|
218
|
+
"help": getattr(cmd, "help", None)
|
219
|
+
or getattr(cmd, "short_help", None),
|
220
|
+
"error": f"Could not generate full help: {str(e)}",
|
221
|
+
}
|
222
|
+
|
223
|
+
# Build comprehensive help structure
|
224
|
+
full_help = {
|
225
|
+
"ostruct_version": __version__,
|
226
|
+
"help_type": "full_cli",
|
227
|
+
"main_command": main_help,
|
228
|
+
"commands": commands_help,
|
229
|
+
"usage_patterns": generate_usage_patterns_from_commands(commands_help),
|
230
|
+
"attachment_system": generate_attachment_system_info(),
|
231
|
+
"json_output_modes": generate_json_output_modes(),
|
232
|
+
}
|
233
|
+
|
234
|
+
click.echo(json.dumps(full_help, indent=2))
|
235
|
+
ctx.exit(0)
|
ostruct/cli/mcp_integration.py
CHANGED
@@ -7,7 +7,7 @@ with the OpenAI Responses API for enhanced functionality in ostruct.
|
|
7
7
|
import logging
|
8
8
|
import re
|
9
9
|
import time
|
10
|
-
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
10
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
|
11
11
|
from urllib.parse import urlparse
|
12
12
|
|
13
13
|
# Import requests for HTTP functionality (used in production)
|
@@ -16,6 +16,11 @@ try:
|
|
16
16
|
except ImportError:
|
17
17
|
requests = None # type: ignore[assignment]
|
18
18
|
|
19
|
+
try:
|
20
|
+
import bleach # type: ignore[import-untyped]
|
21
|
+
except ImportError:
|
22
|
+
bleach = None # type: ignore[assignment]
|
23
|
+
|
19
24
|
if TYPE_CHECKING:
|
20
25
|
from .services import ServiceHealth
|
21
26
|
|
@@ -161,23 +166,15 @@ class MCPClient:
|
|
161
166
|
if not isinstance(text, str):
|
162
167
|
return text
|
163
168
|
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
text,
|
169
|
-
flags=re.IGNORECASE | re.DOTALL,
|
170
|
-
)
|
171
|
-
text = re.sub(r"<script[^>]*>", "", text, flags=re.IGNORECASE)
|
169
|
+
if bleach:
|
170
|
+
# Use bleach to strip all HTML tags, attributes, and styles.
|
171
|
+
# This is the safest way to prevent XSS.
|
172
|
+
text = bleach.clean(text, tags=[], attributes={}, strip=True)
|
172
173
|
|
173
|
-
#
|
174
|
+
# bleach.clean doesn't handle javascript: URIs that are not in an
|
175
|
+
# href attribute, so we remove them explicitly as a safeguard.
|
174
176
|
text = re.sub(r"javascript:", "", text, flags=re.IGNORECASE)
|
175
177
|
|
176
|
-
# Remove other dangerous patterns
|
177
|
-
text = re.sub(
|
178
|
-
r"on\w+\s*=", "", text, flags=re.IGNORECASE
|
179
|
-
) # Event handlers
|
180
|
-
|
181
178
|
return text
|
182
179
|
|
183
180
|
def sanitize_dict(data: Any) -> Any:
|
@@ -193,7 +190,7 @@ class MCPClient:
|
|
193
190
|
else:
|
194
191
|
return data
|
195
192
|
|
196
|
-
return sanitize_dict(response)
|
193
|
+
return cast(Dict[str, Any], sanitize_dict(response))
|
197
194
|
|
198
195
|
def _check_rate_limit(self) -> None:
|
199
196
|
"""Check and enforce rate limiting."""
|
ostruct/cli/params.py
ADDED
@@ -0,0 +1,217 @@
|
|
1
|
+
"""Parameter handling and validation for CLI attachment syntax."""
|
2
|
+
|
3
|
+
from typing import Any, Dict, Optional, Set, Tuple, TypedDict, Union
|
4
|
+
|
5
|
+
import click
|
6
|
+
|
7
|
+
# Target mapping with explicit aliases
|
8
|
+
TARGET_NORMALISE = {
|
9
|
+
"prompt": "prompt",
|
10
|
+
"code-interpreter": "code-interpreter",
|
11
|
+
"ci": "code-interpreter",
|
12
|
+
"file-search": "file-search",
|
13
|
+
"fs": "file-search",
|
14
|
+
}
|
15
|
+
|
16
|
+
|
17
|
+
class AttachmentSpec(TypedDict):
|
18
|
+
"""Type definition for attachment specifications."""
|
19
|
+
|
20
|
+
alias: str
|
21
|
+
path: Union[
|
22
|
+
str, Tuple[str, str]
|
23
|
+
] # str or ("@", "filelist.txt") for collect
|
24
|
+
targets: Set[str]
|
25
|
+
recursive: bool
|
26
|
+
pattern: Optional[str]
|
27
|
+
|
28
|
+
|
29
|
+
def normalise_targets(raw: str) -> Set[str]:
|
30
|
+
"""Normalize comma-separated target list with aliases.
|
31
|
+
|
32
|
+
Args:
|
33
|
+
raw: Comma-separated string of targets (e.g., "prompt,ci,fs")
|
34
|
+
|
35
|
+
Returns:
|
36
|
+
Set of normalized target names
|
37
|
+
|
38
|
+
Raises:
|
39
|
+
click.BadParameter: If any target is unknown
|
40
|
+
|
41
|
+
Examples:
|
42
|
+
>>> normalise_targets("prompt")
|
43
|
+
{"prompt"}
|
44
|
+
>>> normalise_targets("ci,fs")
|
45
|
+
{"code-interpreter", "file-search"}
|
46
|
+
>>> normalise_targets("")
|
47
|
+
{"prompt"}
|
48
|
+
"""
|
49
|
+
if not raw.strip(): # Guard against empty string edge case
|
50
|
+
return {"prompt"}
|
51
|
+
|
52
|
+
tokens = [t.strip().lower() for t in raw.split(",") if t.strip()]
|
53
|
+
if not tokens: # After stripping, no valid tokens remain
|
54
|
+
return {"prompt"}
|
55
|
+
|
56
|
+
# Normalize all tokens and check for unknown ones
|
57
|
+
normalized = set()
|
58
|
+
bad_tokens = set()
|
59
|
+
|
60
|
+
for token in tokens:
|
61
|
+
if token in TARGET_NORMALISE:
|
62
|
+
normalized.add(TARGET_NORMALISE[token])
|
63
|
+
else:
|
64
|
+
bad_tokens.add(token)
|
65
|
+
|
66
|
+
if bad_tokens:
|
67
|
+
valid_targets = ", ".join(sorted(TARGET_NORMALISE.keys()))
|
68
|
+
raise click.BadParameter(
|
69
|
+
f"Unknown target(s): {', '.join(sorted(bad_tokens))}. "
|
70
|
+
f"Valid targets: {valid_targets}"
|
71
|
+
)
|
72
|
+
|
73
|
+
return normalized or {"prompt"} # Fallback to prompt if somehow empty
|
74
|
+
|
75
|
+
|
76
|
+
def validate_attachment_alias(alias: str) -> str:
|
77
|
+
"""Validate and normalize attachment alias.
|
78
|
+
|
79
|
+
Args:
|
80
|
+
alias: The attachment alias to validate
|
81
|
+
|
82
|
+
Returns:
|
83
|
+
The validated alias
|
84
|
+
|
85
|
+
Raises:
|
86
|
+
click.BadParameter: If alias is invalid
|
87
|
+
"""
|
88
|
+
if not alias or not alias.strip():
|
89
|
+
raise click.BadParameter("Attachment alias cannot be empty")
|
90
|
+
|
91
|
+
alias = alias.strip()
|
92
|
+
|
93
|
+
# Basic validation - no whitespace, reasonable length
|
94
|
+
if " " in alias or "\t" in alias:
|
95
|
+
raise click.BadParameter("Attachment alias cannot contain whitespace")
|
96
|
+
|
97
|
+
if len(alias) > 64:
|
98
|
+
raise click.BadParameter(
|
99
|
+
"Attachment alias too long (max 64 characters)"
|
100
|
+
)
|
101
|
+
|
102
|
+
return alias
|
103
|
+
|
104
|
+
|
105
|
+
class AttachParam(click.ParamType):
|
106
|
+
"""Custom Click parameter type for parsing attachment specifications.
|
107
|
+
|
108
|
+
Supports space-form syntax: '[targets:]alias path'
|
109
|
+
|
110
|
+
Examples:
|
111
|
+
--attach data ./file.txt
|
112
|
+
--attach ci:analysis ./data.csv
|
113
|
+
--collect ci,fs:mixed @file-list.txt
|
114
|
+
"""
|
115
|
+
|
116
|
+
name = "attach-spec"
|
117
|
+
|
118
|
+
def __init__(self, multi: bool = False) -> None:
|
119
|
+
"""Initialize AttachParam.
|
120
|
+
|
121
|
+
Args:
|
122
|
+
multi: If True, supports @filelist syntax for collect operations
|
123
|
+
"""
|
124
|
+
self.multi = multi
|
125
|
+
|
126
|
+
def convert(
|
127
|
+
self,
|
128
|
+
value: Any,
|
129
|
+
param: Optional[click.Parameter],
|
130
|
+
ctx: Optional[click.Context],
|
131
|
+
) -> Dict[str, Any]:
|
132
|
+
"""Convert Click parameter value to AttachmentSpec.
|
133
|
+
|
134
|
+
Args:
|
135
|
+
value: Parameter value from Click (tuple for nargs=2)
|
136
|
+
param: Click parameter object
|
137
|
+
ctx: Click context
|
138
|
+
|
139
|
+
Returns:
|
140
|
+
Dict representing an AttachmentSpec
|
141
|
+
|
142
|
+
Raises:
|
143
|
+
click.BadParameter: If value format is invalid
|
144
|
+
"""
|
145
|
+
# Space form only (nargs=2) - Click passes tuple
|
146
|
+
if not isinstance(value, tuple) or len(value) != 2:
|
147
|
+
self._fail_with_usage_examples(
|
148
|
+
"Attachment must use space form syntax", param, ctx
|
149
|
+
)
|
150
|
+
|
151
|
+
spec, path = value
|
152
|
+
|
153
|
+
# Parse spec part: [targets:]alias
|
154
|
+
if ":" in spec:
|
155
|
+
# Check for Windows drive letter false positive (C:\path)
|
156
|
+
if len(spec) == 2 and spec[1] == ":" and spec[0].isalpha():
|
157
|
+
# This is likely a drive letter, treat as alias only
|
158
|
+
prefix, alias = "prompt", spec
|
159
|
+
else:
|
160
|
+
prefix, alias = spec.split(":", 1)
|
161
|
+
else:
|
162
|
+
prefix, alias = "prompt", spec
|
163
|
+
|
164
|
+
# Normalize targets using the existing function
|
165
|
+
try:
|
166
|
+
targets = normalise_targets(prefix)
|
167
|
+
except click.BadParameter:
|
168
|
+
# Re-raise with context about attachment parsing
|
169
|
+
self._fail_with_usage_examples(
|
170
|
+
f"Invalid target(s) in '{prefix}'. Use comma-separated valid targets",
|
171
|
+
param,
|
172
|
+
ctx,
|
173
|
+
)
|
174
|
+
|
175
|
+
# Validate alias
|
176
|
+
try:
|
177
|
+
alias = validate_attachment_alias(alias)
|
178
|
+
except click.BadParameter as e:
|
179
|
+
self._fail_with_usage_examples(str(e), param, ctx)
|
180
|
+
|
181
|
+
# Handle collect @filelist syntax
|
182
|
+
if self.multi and path.startswith("@"):
|
183
|
+
filelist_path = path[1:] # Remove @
|
184
|
+
if not filelist_path:
|
185
|
+
self._fail_with_usage_examples(
|
186
|
+
"Filelist path cannot be empty after @", param, ctx
|
187
|
+
)
|
188
|
+
path = ("@", filelist_path)
|
189
|
+
|
190
|
+
return {
|
191
|
+
"alias": alias,
|
192
|
+
"path": path,
|
193
|
+
"targets": targets,
|
194
|
+
"recursive": False, # Set by flag processing
|
195
|
+
"pattern": None, # Set by flag processing
|
196
|
+
}
|
197
|
+
|
198
|
+
def _fail_with_usage_examples(
|
199
|
+
self,
|
200
|
+
message: str,
|
201
|
+
param: Optional[click.Parameter],
|
202
|
+
ctx: Optional[click.Context],
|
203
|
+
) -> None:
|
204
|
+
"""Provide helpful usage examples in error messages."""
|
205
|
+
examples = [
|
206
|
+
"--file data ./file.txt",
|
207
|
+
"--file ci:analysis ./data.csv",
|
208
|
+
"--dir fs:docs ./documentation",
|
209
|
+
]
|
210
|
+
|
211
|
+
if self.multi:
|
212
|
+
examples.append("--collect ci,fs:mixed @file-list.txt")
|
213
|
+
|
214
|
+
full_message = f"{message}\n\nExamples:\n" + "\n".join(
|
215
|
+
f" {ex}" for ex in examples
|
216
|
+
)
|
217
|
+
self.fail(full_message, param, ctx)
|