ostruct-cli 0.7.2__py3-none-any.whl → 0.8.0__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 +21 -3
- ostruct/cli/base_errors.py +1 -1
- ostruct/cli/cli.py +66 -1983
- ostruct/cli/click_options.py +460 -28
- ostruct/cli/code_interpreter.py +238 -0
- ostruct/cli/commands/__init__.py +32 -0
- ostruct/cli/commands/list_models.py +128 -0
- ostruct/cli/commands/quick_ref.py +50 -0
- ostruct/cli/commands/run.py +137 -0
- ostruct/cli/commands/update_registry.py +71 -0
- ostruct/cli/config.py +277 -0
- ostruct/cli/cost_estimation.py +134 -0
- ostruct/cli/errors.py +310 -6
- ostruct/cli/exit_codes.py +1 -0
- ostruct/cli/explicit_file_processor.py +548 -0
- ostruct/cli/field_utils.py +69 -0
- ostruct/cli/file_info.py +42 -9
- ostruct/cli/file_list.py +301 -102
- ostruct/cli/file_search.py +455 -0
- ostruct/cli/file_utils.py +47 -13
- ostruct/cli/mcp_integration.py +541 -0
- ostruct/cli/model_creation.py +150 -1
- ostruct/cli/model_validation.py +204 -0
- ostruct/cli/progress_reporting.py +398 -0
- ostruct/cli/registry_updates.py +14 -9
- ostruct/cli/runner.py +1418 -0
- ostruct/cli/schema_utils.py +113 -0
- ostruct/cli/services.py +626 -0
- ostruct/cli/template_debug.py +748 -0
- ostruct/cli/template_debug_help.py +162 -0
- ostruct/cli/template_env.py +15 -6
- ostruct/cli/template_filters.py +55 -3
- ostruct/cli/template_optimizer.py +474 -0
- ostruct/cli/template_processor.py +1080 -0
- ostruct/cli/template_rendering.py +69 -34
- ostruct/cli/token_validation.py +286 -0
- ostruct/cli/types.py +78 -0
- ostruct/cli/unattended_operation.py +269 -0
- ostruct/cli/validators.py +386 -3
- {ostruct_cli-0.7.2.dist-info → ostruct_cli-0.8.0.dist-info}/LICENSE +2 -0
- ostruct_cli-0.8.0.dist-info/METADATA +633 -0
- ostruct_cli-0.8.0.dist-info/RECORD +69 -0
- {ostruct_cli-0.7.2.dist-info → ostruct_cli-0.8.0.dist-info}/WHEEL +1 -1
- ostruct_cli-0.7.2.dist-info/METADATA +0 -370
- ostruct_cli-0.7.2.dist-info/RECORD +0 -45
- {ostruct_cli-0.7.2.dist-info → ostruct_cli-0.8.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,269 @@
|
|
1
|
+
"""Unattended operation safeguards for CI/CD compatibility."""
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import logging
|
5
|
+
from typing import Any, Callable, List, Optional
|
6
|
+
|
7
|
+
from .errors import CLIError, ContainerExpiredError, ExitCode
|
8
|
+
|
9
|
+
logger = logging.getLogger(__name__)
|
10
|
+
|
11
|
+
|
12
|
+
class UnattendedOperationTimeoutError(CLIError):
|
13
|
+
"""Operation timed out during unattended execution."""
|
14
|
+
|
15
|
+
def __init__(
|
16
|
+
self,
|
17
|
+
message: str,
|
18
|
+
timeout_seconds: int,
|
19
|
+
exit_code: ExitCode = ExitCode.OPERATION_TIMEOUT,
|
20
|
+
context: Optional[dict] = None,
|
21
|
+
):
|
22
|
+
"""Initialize timeout error with automation guidance.
|
23
|
+
|
24
|
+
Args:
|
25
|
+
message: Error message
|
26
|
+
timeout_seconds: The timeout that was exceeded
|
27
|
+
exit_code: Exit code for automation
|
28
|
+
context: Additional context information
|
29
|
+
"""
|
30
|
+
enhanced_message = (
|
31
|
+
f"{message}\n\n"
|
32
|
+
f"💡 For CI/CD environments:\n"
|
33
|
+
f" • Increase timeout with --timeout {timeout_seconds * 2}\n"
|
34
|
+
f" • Split large operations into smaller chunks\n"
|
35
|
+
f" • Use --dry-run to validate before actual execution\n"
|
36
|
+
f" • Check server/tool availability before running"
|
37
|
+
)
|
38
|
+
|
39
|
+
enhanced_context = {
|
40
|
+
"timeout_seconds": timeout_seconds,
|
41
|
+
"automation_friendly": True,
|
42
|
+
**(context or {}),
|
43
|
+
}
|
44
|
+
|
45
|
+
super().__init__(
|
46
|
+
enhanced_message, exit_code=exit_code, context=enhanced_context
|
47
|
+
)
|
48
|
+
|
49
|
+
|
50
|
+
class UnattendedOperationManager:
|
51
|
+
"""Manager for unattended operations with timeout and error handling."""
|
52
|
+
|
53
|
+
def __init__(self, timeout_seconds: int = 3600): # 1 hour default
|
54
|
+
"""Initialize unattended operation manager.
|
55
|
+
|
56
|
+
Args:
|
57
|
+
timeout_seconds: Maximum operation timeout in seconds
|
58
|
+
"""
|
59
|
+
self.timeout = timeout_seconds
|
60
|
+
logger.debug(
|
61
|
+
f"UnattendedOperationManager initialized with {timeout_seconds}s timeout"
|
62
|
+
)
|
63
|
+
|
64
|
+
async def execute_with_safeguards(
|
65
|
+
self, operation: Callable, operation_name: str = "operation"
|
66
|
+
) -> Any:
|
67
|
+
"""Execute operation with timeout and error handling.
|
68
|
+
|
69
|
+
Args:
|
70
|
+
operation: Async callable to execute
|
71
|
+
operation_name: Human-readable name for logging and errors
|
72
|
+
|
73
|
+
Returns:
|
74
|
+
Result of the operation
|
75
|
+
|
76
|
+
Raises:
|
77
|
+
UnattendedOperationTimeoutError: If operation times out
|
78
|
+
ContainerExpiredError: If container expires (fail fast)
|
79
|
+
CLIError: For other operation failures
|
80
|
+
"""
|
81
|
+
logger.debug(
|
82
|
+
f"Starting unattended {operation_name} with {self.timeout}s timeout"
|
83
|
+
)
|
84
|
+
|
85
|
+
try:
|
86
|
+
result = await asyncio.wait_for(operation(), timeout=self.timeout)
|
87
|
+
logger.debug(f"Unattended {operation_name} completed successfully")
|
88
|
+
return result
|
89
|
+
|
90
|
+
except asyncio.TimeoutError:
|
91
|
+
error_msg = f"Unattended {operation_name} timed out after {self.timeout} seconds"
|
92
|
+
logger.error(error_msg)
|
93
|
+
raise UnattendedOperationTimeoutError(
|
94
|
+
error_msg,
|
95
|
+
timeout_seconds=self.timeout,
|
96
|
+
context={"operation": operation_name},
|
97
|
+
)
|
98
|
+
|
99
|
+
except ContainerExpiredError:
|
100
|
+
# Fail fast for container expiration - these are unrecoverable
|
101
|
+
logger.error(
|
102
|
+
f"Container expired during {operation_name} - failing fast"
|
103
|
+
)
|
104
|
+
raise
|
105
|
+
|
106
|
+
except CLIError:
|
107
|
+
# Re-raise CLI errors as-is (they're already properly formatted)
|
108
|
+
raise
|
109
|
+
|
110
|
+
except Exception as e:
|
111
|
+
# Wrap unexpected errors for better automation handling
|
112
|
+
error_msg = (
|
113
|
+
f"Unexpected error during unattended {operation_name}: {e}"
|
114
|
+
)
|
115
|
+
logger.error(error_msg)
|
116
|
+
raise CLIError(
|
117
|
+
error_msg,
|
118
|
+
exit_code=ExitCode.INTERNAL_ERROR,
|
119
|
+
context={
|
120
|
+
"operation": operation_name,
|
121
|
+
"original_error": str(e),
|
122
|
+
},
|
123
|
+
)
|
124
|
+
|
125
|
+
def set_timeout(self, timeout_seconds: int) -> None:
|
126
|
+
"""Update operation timeout.
|
127
|
+
|
128
|
+
Args:
|
129
|
+
timeout_seconds: New timeout in seconds
|
130
|
+
"""
|
131
|
+
old_timeout = self.timeout
|
132
|
+
self.timeout = timeout_seconds
|
133
|
+
logger.debug(f"Timeout updated: {old_timeout}s -> {timeout_seconds}s")
|
134
|
+
|
135
|
+
|
136
|
+
class UnattendedCompatibilityValidator:
|
137
|
+
"""Validator for ensuring operations can run without user interaction."""
|
138
|
+
|
139
|
+
@staticmethod
|
140
|
+
def validate_mcp_servers(servers: List[dict]) -> List[str]:
|
141
|
+
"""Pre-validate MCP servers don't require interaction.
|
142
|
+
|
143
|
+
Args:
|
144
|
+
servers: List of MCP server configurations
|
145
|
+
|
146
|
+
Returns:
|
147
|
+
List of validation errors, empty if all servers are compatible
|
148
|
+
"""
|
149
|
+
errors = []
|
150
|
+
|
151
|
+
for server in servers:
|
152
|
+
server_url = server.get("url", "unknown")
|
153
|
+
|
154
|
+
# Check approval requirement
|
155
|
+
require_approval = server.get("require_approval", "user")
|
156
|
+
if require_approval != "never":
|
157
|
+
errors.append(
|
158
|
+
f"MCP server {server_url} requires approval ('{require_approval}') - "
|
159
|
+
f"incompatible with unattended CLI usage. Set require_approval='never'."
|
160
|
+
)
|
161
|
+
|
162
|
+
# Check for interactive features that break automation
|
163
|
+
if server.get("interactive_mode", False):
|
164
|
+
errors.append(
|
165
|
+
f"MCP server {server_url} has interactive_mode enabled - "
|
166
|
+
f"incompatible with unattended operation"
|
167
|
+
)
|
168
|
+
|
169
|
+
# Check for user prompt features
|
170
|
+
if server.get("user_prompts", False):
|
171
|
+
errors.append(
|
172
|
+
f"MCP server {server_url} enables user_prompts - "
|
173
|
+
f"incompatible with unattended operation"
|
174
|
+
)
|
175
|
+
|
176
|
+
return errors
|
177
|
+
|
178
|
+
@staticmethod
|
179
|
+
def validate_tool_configurations(tool_configs: List[dict]) -> List[str]:
|
180
|
+
"""Validate tool configurations for unattended operation.
|
181
|
+
|
182
|
+
Args:
|
183
|
+
tool_configs: List of tool configurations
|
184
|
+
|
185
|
+
Returns:
|
186
|
+
List of validation errors, empty if all tools are compatible
|
187
|
+
"""
|
188
|
+
errors = []
|
189
|
+
|
190
|
+
for tool_config in tool_configs:
|
191
|
+
tool_type = tool_config.get("type", "unknown")
|
192
|
+
|
193
|
+
# Validate MCP tools
|
194
|
+
if tool_type == "mcp":
|
195
|
+
if tool_config.get("require_approval") != "never":
|
196
|
+
errors.append(
|
197
|
+
f"MCP tool {tool_config.get('server_label', 'unknown')} "
|
198
|
+
f"requires approval - incompatible with unattended operation"
|
199
|
+
)
|
200
|
+
|
201
|
+
# Validate Code Interpreter - should be fine for unattended use
|
202
|
+
elif tool_type == "code_interpreter":
|
203
|
+
# Code Interpreter is inherently unattended-compatible
|
204
|
+
pass
|
205
|
+
|
206
|
+
# Validate File Search - should be fine for unattended use
|
207
|
+
elif tool_type == "file_search":
|
208
|
+
# File Search is inherently unattended-compatible
|
209
|
+
pass
|
210
|
+
|
211
|
+
# Unknown tool types - warn but don't fail
|
212
|
+
else:
|
213
|
+
logger.warning(
|
214
|
+
f"Unknown tool type '{tool_type}' - cannot validate for unattended operation"
|
215
|
+
)
|
216
|
+
|
217
|
+
return errors
|
218
|
+
|
219
|
+
@staticmethod
|
220
|
+
def get_automation_recommendations() -> List[str]:
|
221
|
+
"""Get recommendations for better automation compatibility.
|
222
|
+
|
223
|
+
Returns:
|
224
|
+
List of recommendations for CI/CD usage
|
225
|
+
"""
|
226
|
+
return [
|
227
|
+
"Use --dry-run to validate configuration before actual execution",
|
228
|
+
"Set explicit timeouts with --timeout for long-running operations",
|
229
|
+
"Configure MCP servers with require_approval='never'",
|
230
|
+
"Use structured logging for better CI/CD integration",
|
231
|
+
"Test operations locally before deploying to CI/CD",
|
232
|
+
"Monitor container expiration limits (20min runtime, 2min idle)",
|
233
|
+
"Use retry logic for transient network failures",
|
234
|
+
"Validate API keys and permissions before execution",
|
235
|
+
]
|
236
|
+
|
237
|
+
|
238
|
+
def ensure_unattended_compatibility(
|
239
|
+
mcp_servers: Optional[List[dict]] = None,
|
240
|
+
tool_configs: Optional[List[dict]] = None,
|
241
|
+
timeout_seconds: int = 3600,
|
242
|
+
) -> tuple[UnattendedOperationManager, List[str]]:
|
243
|
+
"""Ensure complete unattended operation compatibility.
|
244
|
+
|
245
|
+
Args:
|
246
|
+
mcp_servers: Optional list of MCP server configurations
|
247
|
+
tool_configs: Optional list of tool configurations
|
248
|
+
timeout_seconds: Operation timeout in seconds
|
249
|
+
|
250
|
+
Returns:
|
251
|
+
Tuple of (operation_manager, validation_errors)
|
252
|
+
"""
|
253
|
+
validator = UnattendedCompatibilityValidator()
|
254
|
+
all_errors = []
|
255
|
+
|
256
|
+
# Validate MCP servers
|
257
|
+
if mcp_servers:
|
258
|
+
mcp_errors = validator.validate_mcp_servers(mcp_servers)
|
259
|
+
all_errors.extend(mcp_errors)
|
260
|
+
|
261
|
+
# Validate tool configurations
|
262
|
+
if tool_configs:
|
263
|
+
tool_errors = validator.validate_tool_configurations(tool_configs)
|
264
|
+
all_errors.extend(tool_errors)
|
265
|
+
|
266
|
+
# Create operation manager
|
267
|
+
operation_manager = UnattendedOperationManager(timeout_seconds)
|
268
|
+
|
269
|
+
return operation_manager, all_errors
|
ostruct/cli/validators.py
CHANGED
@@ -1,12 +1,36 @@
|
|
1
1
|
"""Validators for CLI options and arguments."""
|
2
2
|
|
3
3
|
import json
|
4
|
+
import logging
|
5
|
+
import os
|
4
6
|
from pathlib import Path
|
5
|
-
from typing import Any, List, Optional, Tuple, Union
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
6
8
|
|
7
9
|
import click
|
10
|
+
import jinja2
|
8
11
|
|
9
|
-
from .errors import
|
12
|
+
from .errors import (
|
13
|
+
DirectoryNotFoundError,
|
14
|
+
InvalidJSONError,
|
15
|
+
SchemaFileError,
|
16
|
+
SchemaValidationError,
|
17
|
+
VariableNameError,
|
18
|
+
VariableValueError,
|
19
|
+
)
|
20
|
+
from .explicit_file_processor import ExplicitFileProcessor
|
21
|
+
from .security import SecurityManager
|
22
|
+
from .template_env import create_jinja_env
|
23
|
+
from .template_processor import (
|
24
|
+
create_template_context_from_routing,
|
25
|
+
validate_task_template,
|
26
|
+
)
|
27
|
+
from .template_utils import validate_json_schema
|
28
|
+
from .types import CLIParams
|
29
|
+
|
30
|
+
logger = logging.getLogger(__name__)
|
31
|
+
|
32
|
+
# Type alias for file routing results
|
33
|
+
FileRoutingResult = List[Tuple[Optional[str], Union[str, Path]]]
|
10
34
|
|
11
35
|
|
12
36
|
def validate_name_path_pair(
|
@@ -38,6 +62,68 @@ def validate_name_path_pair(
|
|
38
62
|
return result
|
39
63
|
|
40
64
|
|
65
|
+
def validate_file_routing_spec(
|
66
|
+
ctx: click.Context,
|
67
|
+
param: click.Parameter,
|
68
|
+
value: List[str],
|
69
|
+
) -> FileRoutingResult:
|
70
|
+
"""Validate file routing specifications supporting multiple syntaxes.
|
71
|
+
|
72
|
+
Supports two syntaxes currently:
|
73
|
+
- Simple path: -ft config.yaml (auto-generates variable name)
|
74
|
+
- Equals syntax: -ft code_file=src/main.py (custom variable name)
|
75
|
+
|
76
|
+
Note: Two-argument syntax (-ft name path) requires special handling at the CLI level
|
77
|
+
and is not supported in this validator due to Click's argument processing.
|
78
|
+
|
79
|
+
Args:
|
80
|
+
ctx: Click context
|
81
|
+
param: Click parameter
|
82
|
+
value: List of file specifications from multiple options
|
83
|
+
|
84
|
+
Returns:
|
85
|
+
List of (variable_name, path) tuples where variable_name=None means auto-generate
|
86
|
+
|
87
|
+
Raises:
|
88
|
+
click.BadParameter: If validation fails
|
89
|
+
"""
|
90
|
+
if not value:
|
91
|
+
return []
|
92
|
+
|
93
|
+
result: FileRoutingResult = []
|
94
|
+
|
95
|
+
for spec in value:
|
96
|
+
if "=" in spec:
|
97
|
+
# Equals syntax: -ft code_file=src/main.py
|
98
|
+
if spec.count("=") != 1:
|
99
|
+
raise click.BadParameter(
|
100
|
+
f"Invalid format '{spec}'. Use name=path or just path."
|
101
|
+
)
|
102
|
+
name, path = spec.split("=", 1)
|
103
|
+
if not name.strip():
|
104
|
+
raise click.BadParameter(f"Empty variable name in '{spec}'")
|
105
|
+
if not path.strip():
|
106
|
+
raise click.BadParameter(f"Empty path in '{spec}'")
|
107
|
+
name = name.strip()
|
108
|
+
path = path.strip()
|
109
|
+
if not name.isidentifier():
|
110
|
+
raise click.BadParameter(f"Invalid variable name: {name}")
|
111
|
+
if not Path(path).exists():
|
112
|
+
raise click.BadParameter(f"File not found: {path}")
|
113
|
+
result.append((name, Path(path)))
|
114
|
+
else:
|
115
|
+
# Simple path: -ft config.yaml
|
116
|
+
path = spec.strip()
|
117
|
+
if not path:
|
118
|
+
raise click.BadParameter("Empty file path")
|
119
|
+
if not Path(path).exists():
|
120
|
+
raise click.BadParameter(f"File not found: {path}")
|
121
|
+
# Mark as auto-name with None, will be processed later
|
122
|
+
result.append((None, Path(path)))
|
123
|
+
|
124
|
+
return result
|
125
|
+
|
126
|
+
|
41
127
|
def validate_variable(
|
42
128
|
ctx: click.Context, param: click.Parameter, value: Optional[List[str]]
|
43
129
|
) -> Optional[List[Tuple[str, str]]]:
|
@@ -95,7 +181,7 @@ def validate_json_variable(
|
|
95
181
|
for var in value:
|
96
182
|
if "=" not in var:
|
97
183
|
raise InvalidJSONError(
|
98
|
-
f
|
184
|
+
f"JSON variable must be in format name='{'json':\"value\"}': {var}"
|
99
185
|
)
|
100
186
|
name, json_str = var.split("=", 1)
|
101
187
|
name = name.strip()
|
@@ -111,3 +197,300 @@ def validate_json_variable(
|
|
111
197
|
context={"variable_name": name},
|
112
198
|
) from e
|
113
199
|
return result
|
200
|
+
|
201
|
+
|
202
|
+
def parse_var(var_str: str) -> Tuple[str, str]:
|
203
|
+
"""Parse a simple variable string in the format 'name=value'.
|
204
|
+
|
205
|
+
Args:
|
206
|
+
var_str: Variable string in format 'name=value'
|
207
|
+
|
208
|
+
Returns:
|
209
|
+
Tuple of (name, value)
|
210
|
+
|
211
|
+
Raises:
|
212
|
+
VariableNameError: If variable name is empty or invalid
|
213
|
+
VariableValueError: If variable format is invalid
|
214
|
+
"""
|
215
|
+
try:
|
216
|
+
name, value = var_str.split("=", 1)
|
217
|
+
if not name:
|
218
|
+
raise VariableNameError("Empty name in variable mapping")
|
219
|
+
if not name.isidentifier():
|
220
|
+
raise VariableNameError(
|
221
|
+
f"Invalid variable name: {name}. Must be a valid Python identifier"
|
222
|
+
)
|
223
|
+
return name, value
|
224
|
+
except ValueError as e:
|
225
|
+
if "not enough values to unpack" in str(e):
|
226
|
+
raise VariableValueError(
|
227
|
+
f"Invalid variable mapping (expected name=value format): {var_str!r}"
|
228
|
+
)
|
229
|
+
raise
|
230
|
+
|
231
|
+
|
232
|
+
def parse_json_var(var_str: str) -> Tuple[str, Any]:
|
233
|
+
"""Parse a JSON variable string in the format 'name=json_value'.
|
234
|
+
|
235
|
+
Args:
|
236
|
+
var_str: Variable string in format 'name=json_value'
|
237
|
+
|
238
|
+
Returns:
|
239
|
+
Tuple of (name, parsed_value)
|
240
|
+
|
241
|
+
Raises:
|
242
|
+
VariableNameError: If variable name is empty or invalid
|
243
|
+
VariableValueError: If variable format is invalid
|
244
|
+
InvalidJSONError: If JSON value is invalid
|
245
|
+
"""
|
246
|
+
try:
|
247
|
+
name, json_str = var_str.split("=", 1)
|
248
|
+
if not name:
|
249
|
+
raise VariableNameError("Empty name in JSON variable mapping")
|
250
|
+
if not name.isidentifier():
|
251
|
+
raise VariableNameError(
|
252
|
+
f"Invalid variable name: {name}. Must be a valid Python identifier"
|
253
|
+
)
|
254
|
+
|
255
|
+
try:
|
256
|
+
value = json.loads(json_str)
|
257
|
+
except json.JSONDecodeError as e:
|
258
|
+
raise InvalidJSONError(
|
259
|
+
f"Error parsing JSON for variable '{name}': {str(e)}. Input was: {json_str}",
|
260
|
+
context={"variable_name": name},
|
261
|
+
)
|
262
|
+
|
263
|
+
return name, value
|
264
|
+
|
265
|
+
except ValueError as e:
|
266
|
+
if "not enough values to unpack" in str(e):
|
267
|
+
raise VariableValueError(
|
268
|
+
f"Invalid JSON variable mapping (expected name=json format): {var_str!r}"
|
269
|
+
)
|
270
|
+
raise
|
271
|
+
|
272
|
+
|
273
|
+
def validate_variable_mapping(
|
274
|
+
mapping: str, is_json: bool = False
|
275
|
+
) -> tuple[str, Any]:
|
276
|
+
"""Validate a variable mapping in name=value format."""
|
277
|
+
try:
|
278
|
+
name, value = mapping.split("=", 1)
|
279
|
+
if not name:
|
280
|
+
raise VariableNameError(
|
281
|
+
f"Empty name in {'JSON ' if is_json else ''}variable mapping"
|
282
|
+
)
|
283
|
+
|
284
|
+
if is_json:
|
285
|
+
try:
|
286
|
+
value = json.loads(value)
|
287
|
+
except json.JSONDecodeError as e:
|
288
|
+
raise InvalidJSONError(
|
289
|
+
f"Invalid JSON value for variable {name!r}: {value!r}",
|
290
|
+
context={"variable_name": name},
|
291
|
+
) from e
|
292
|
+
|
293
|
+
return name, value
|
294
|
+
|
295
|
+
except ValueError as e:
|
296
|
+
if "not enough values to unpack" in str(e):
|
297
|
+
raise VariableValueError(
|
298
|
+
f"Invalid {'JSON ' if is_json else ''}variable mapping "
|
299
|
+
f"(expected name=value format): {mapping!r}"
|
300
|
+
)
|
301
|
+
raise
|
302
|
+
|
303
|
+
|
304
|
+
def validate_schema_file(
|
305
|
+
path: str,
|
306
|
+
verbose: bool = False,
|
307
|
+
) -> Dict[str, Any]:
|
308
|
+
"""Validate and load a JSON schema file.
|
309
|
+
|
310
|
+
Args:
|
311
|
+
path: Path to the schema file
|
312
|
+
verbose: Whether to log additional information
|
313
|
+
|
314
|
+
Returns:
|
315
|
+
Dictionary containing the schema data
|
316
|
+
|
317
|
+
Raises:
|
318
|
+
SchemaFileError: If the schema file is invalid
|
319
|
+
"""
|
320
|
+
try:
|
321
|
+
with Path(path).open("r") as f:
|
322
|
+
schema_data = json.load(f)
|
323
|
+
|
324
|
+
# Validate basic schema structure
|
325
|
+
if not isinstance(schema_data, dict):
|
326
|
+
raise SchemaFileError(
|
327
|
+
f"Schema file must contain a JSON object, got {type(schema_data).__name__}",
|
328
|
+
schema_path=path,
|
329
|
+
)
|
330
|
+
|
331
|
+
# Must have either "schema" key or be a direct schema
|
332
|
+
if "schema" in schema_data:
|
333
|
+
# Wrapped schema format
|
334
|
+
actual_schema = schema_data["schema"]
|
335
|
+
else:
|
336
|
+
# Direct schema format
|
337
|
+
actual_schema = schema_data
|
338
|
+
|
339
|
+
if not isinstance(actual_schema, dict):
|
340
|
+
raise SchemaFileError(
|
341
|
+
f"Schema must be a JSON object, got {type(actual_schema).__name__}",
|
342
|
+
schema_path=path,
|
343
|
+
)
|
344
|
+
|
345
|
+
# Validate that root type is object for structured output
|
346
|
+
if actual_schema.get("type") != "object":
|
347
|
+
raise SchemaFileError(
|
348
|
+
f"Schema root type must be 'object', got '{actual_schema.get('type')}'",
|
349
|
+
schema_path=path,
|
350
|
+
)
|
351
|
+
|
352
|
+
return schema_data
|
353
|
+
|
354
|
+
except json.JSONDecodeError as e:
|
355
|
+
raise InvalidJSONError(
|
356
|
+
f"Invalid JSON in schema file: {e}",
|
357
|
+
source=path,
|
358
|
+
) from e
|
359
|
+
except FileNotFoundError as e:
|
360
|
+
raise SchemaFileError(
|
361
|
+
f"Schema file not found: {path}",
|
362
|
+
schema_path=path,
|
363
|
+
) from e
|
364
|
+
except Exception as e:
|
365
|
+
raise SchemaFileError(
|
366
|
+
f"Error reading schema file: {e}",
|
367
|
+
schema_path=path,
|
368
|
+
) from e
|
369
|
+
|
370
|
+
|
371
|
+
def validate_security_manager(
|
372
|
+
base_dir: Optional[str] = None,
|
373
|
+
allowed_dirs: Optional[List[str]] = None,
|
374
|
+
allowed_dir_file: Optional[str] = None,
|
375
|
+
) -> SecurityManager:
|
376
|
+
"""Validate and create security manager.
|
377
|
+
|
378
|
+
Args:
|
379
|
+
base_dir: Base directory for file access. Defaults to current working directory.
|
380
|
+
allowed_dirs: Optional list of additional allowed directories
|
381
|
+
allowed_dir_file: Optional file containing allowed directories
|
382
|
+
|
383
|
+
Returns:
|
384
|
+
Configured SecurityManager instance
|
385
|
+
|
386
|
+
Raises:
|
387
|
+
PathSecurityError: If any paths violate security constraints
|
388
|
+
DirectoryNotFoundError: If any directories do not exist
|
389
|
+
"""
|
390
|
+
# Use current working directory if base_dir is None
|
391
|
+
if base_dir is None:
|
392
|
+
base_dir = os.getcwd()
|
393
|
+
|
394
|
+
# Create security manager with base directory
|
395
|
+
security_manager = SecurityManager(base_dir)
|
396
|
+
|
397
|
+
# Add explicitly allowed directories
|
398
|
+
if allowed_dirs:
|
399
|
+
for dir_path in allowed_dirs:
|
400
|
+
security_manager.add_allowed_directory(dir_path)
|
401
|
+
|
402
|
+
# Add directories from file if specified
|
403
|
+
if allowed_dir_file:
|
404
|
+
try:
|
405
|
+
with open(allowed_dir_file, "r", encoding="utf-8") as f:
|
406
|
+
for line in f:
|
407
|
+
line = line.strip()
|
408
|
+
if line and not line.startswith("#"):
|
409
|
+
security_manager.add_allowed_directory(line)
|
410
|
+
except OSError as e:
|
411
|
+
raise DirectoryNotFoundError(
|
412
|
+
f"Failed to read allowed directories file: {e}"
|
413
|
+
)
|
414
|
+
|
415
|
+
return security_manager
|
416
|
+
|
417
|
+
|
418
|
+
async def validate_inputs(
|
419
|
+
args: CLIParams,
|
420
|
+
) -> Tuple[
|
421
|
+
SecurityManager,
|
422
|
+
str,
|
423
|
+
Dict[str, Any],
|
424
|
+
Dict[str, Any],
|
425
|
+
jinja2.Environment,
|
426
|
+
Optional[str],
|
427
|
+
]:
|
428
|
+
"""Validate all input parameters and return validated components.
|
429
|
+
|
430
|
+
Args:
|
431
|
+
args: Command line arguments
|
432
|
+
|
433
|
+
Returns:
|
434
|
+
Tuple containing:
|
435
|
+
- SecurityManager instance
|
436
|
+
- Task template string
|
437
|
+
- Schema dictionary
|
438
|
+
- Template context dictionary
|
439
|
+
- Jinja2 environment
|
440
|
+
- Template file path (if from file)
|
441
|
+
|
442
|
+
Raises:
|
443
|
+
CLIError: For various validation errors
|
444
|
+
SchemaValidationError: When schema is invalid
|
445
|
+
"""
|
446
|
+
logger.debug("=== Input Validation Phase ===")
|
447
|
+
security_manager = validate_security_manager(
|
448
|
+
base_dir=args.get("base_dir"),
|
449
|
+
allowed_dirs=args.get("allowed_dirs"),
|
450
|
+
allowed_dir_file=args.get("allowed_dir_file"),
|
451
|
+
)
|
452
|
+
|
453
|
+
# Process explicit file routing (T2.4)
|
454
|
+
logger.debug("Processing explicit file routing")
|
455
|
+
file_processor = ExplicitFileProcessor(security_manager)
|
456
|
+
routing_result = await file_processor.process_file_routing(args) # type: ignore[arg-type]
|
457
|
+
|
458
|
+
# Display auto-enablement feedback to user
|
459
|
+
if routing_result.auto_enabled_feedback:
|
460
|
+
print(routing_result.auto_enabled_feedback)
|
461
|
+
|
462
|
+
# Store routing result in args for use by tool processors
|
463
|
+
args["_routing_result"] = routing_result # type: ignore[typeddict-unknown-key]
|
464
|
+
|
465
|
+
task_template = validate_task_template(
|
466
|
+
args.get("task"), args.get("task_file")
|
467
|
+
)
|
468
|
+
|
469
|
+
# Load and validate schema
|
470
|
+
logger.debug("Validating schema from %s", args["schema_file"])
|
471
|
+
try:
|
472
|
+
schema = validate_schema_file(
|
473
|
+
args["schema_file"], args.get("verbose", False)
|
474
|
+
)
|
475
|
+
|
476
|
+
# Validate schema structure before any model creation
|
477
|
+
validate_json_schema(
|
478
|
+
schema
|
479
|
+
) # This will raise SchemaValidationError if invalid
|
480
|
+
except SchemaValidationError as e:
|
481
|
+
logger.error("Schema validation error: %s", str(e))
|
482
|
+
raise # Re-raise the SchemaValidationError to preserve the error chain
|
483
|
+
|
484
|
+
template_context = await create_template_context_from_routing(
|
485
|
+
args, security_manager, routing_result
|
486
|
+
)
|
487
|
+
env = create_jinja_env()
|
488
|
+
|
489
|
+
return (
|
490
|
+
security_manager,
|
491
|
+
task_template,
|
492
|
+
schema,
|
493
|
+
template_context,
|
494
|
+
env,
|
495
|
+
args.get("task_file"),
|
496
|
+
)
|