ostruct-cli 0.8.8__py3-none-any.whl → 1.0.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.
Files changed (50) hide show
  1. ostruct/cli/__init__.py +3 -15
  2. ostruct/cli/attachment_processor.py +455 -0
  3. ostruct/cli/attachment_template_bridge.py +973 -0
  4. ostruct/cli/cli.py +187 -33
  5. ostruct/cli/click_options.py +775 -692
  6. ostruct/cli/code_interpreter.py +195 -12
  7. ostruct/cli/commands/__init__.py +0 -3
  8. ostruct/cli/commands/run.py +289 -62
  9. ostruct/cli/config.py +23 -22
  10. ostruct/cli/constants.py +89 -0
  11. ostruct/cli/errors.py +191 -6
  12. ostruct/cli/explicit_file_processor.py +0 -15
  13. ostruct/cli/file_info.py +118 -14
  14. ostruct/cli/file_list.py +82 -1
  15. ostruct/cli/file_search.py +68 -2
  16. ostruct/cli/help_json.py +235 -0
  17. ostruct/cli/mcp_integration.py +13 -16
  18. ostruct/cli/params.py +217 -0
  19. ostruct/cli/plan_assembly.py +335 -0
  20. ostruct/cli/plan_printing.py +385 -0
  21. ostruct/cli/progress_reporting.py +8 -56
  22. ostruct/cli/quick_ref_help.py +128 -0
  23. ostruct/cli/rich_config.py +299 -0
  24. ostruct/cli/runner.py +397 -190
  25. ostruct/cli/security/__init__.py +2 -0
  26. ostruct/cli/security/allowed_checker.py +41 -0
  27. ostruct/cli/security/normalization.py +13 -9
  28. ostruct/cli/security/security_manager.py +558 -17
  29. ostruct/cli/security/types.py +15 -0
  30. ostruct/cli/template_debug.py +283 -261
  31. ostruct/cli/template_debug_help.py +233 -142
  32. ostruct/cli/template_env.py +46 -5
  33. ostruct/cli/template_filters.py +415 -8
  34. ostruct/cli/template_processor.py +240 -619
  35. ostruct/cli/template_rendering.py +49 -73
  36. ostruct/cli/template_validation.py +2 -1
  37. ostruct/cli/token_validation.py +35 -15
  38. ostruct/cli/types.py +15 -19
  39. ostruct/cli/unicode_compat.py +283 -0
  40. ostruct/cli/upload_manager.py +448 -0
  41. ostruct/cli/utils.py +30 -0
  42. ostruct/cli/validators.py +272 -54
  43. {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/METADATA +292 -126
  44. ostruct_cli-1.0.0.dist-info/RECORD +80 -0
  45. ostruct/cli/commands/quick_ref.py +0 -54
  46. ostruct/cli/template_optimizer.py +0 -478
  47. ostruct_cli-0.8.8.dist-info/RECORD +0 -71
  48. {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/LICENSE +0 -0
  49. {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/WHEEL +0 -0
  50. {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/entry_points.txt +0 -0
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)
@@ -0,0 +1,335 @@
1
+ """Plan assembly for execution plans and run summaries.
2
+
3
+ This module provides the single source of truth for plan data structures
4
+ following UNIFIED GUIDELINES to prevent logic drift between JSON and human output.
5
+ """
6
+
7
+ import os
8
+ import time
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from .attachment_processor import ProcessedAttachments
14
+
15
+
16
+ class PlanAssembler:
17
+ """Single source of truth for execution plan data structure.
18
+
19
+ This class ensures consistent plan format across all output types
20
+ (JSON, human-readable, etc.) by providing a single build method.
21
+ """
22
+
23
+ @staticmethod
24
+ def validate_download_configuration(
25
+ enabled_tools: Optional[set[str]] = None,
26
+ ci_config: Optional[Dict[str, Any]] = None,
27
+ expected_files: Optional[List[str]] = None,
28
+ ) -> Dict[str, Any]:
29
+ """Validate download configuration for dry-run.
30
+
31
+ Args:
32
+ enabled_tools: Set of enabled tools
33
+ ci_config: Code Interpreter configuration
34
+ expected_files: List of expected output filenames (optional)
35
+
36
+ Returns:
37
+ Dictionary with validation results
38
+ """
39
+ validation: Dict[str, Any] = {
40
+ "enabled": False,
41
+ "directory": None,
42
+ "writable": False,
43
+ "conflicts": [],
44
+ "issues": [],
45
+ }
46
+
47
+ # Check if Code Interpreter is enabled
48
+ if not enabled_tools or "code-interpreter" not in enabled_tools:
49
+ return validation
50
+
51
+ validation["enabled"] = True
52
+
53
+ # Get download directory
54
+
55
+ download_dir = "./downloads" # Default
56
+ if ci_config:
57
+ download_dir = ci_config.get("output_directory", download_dir)
58
+
59
+ validation["directory"] = download_dir
60
+
61
+ # Check directory permissions
62
+ try:
63
+ download_path = Path(download_dir)
64
+ parent_dir = download_path.parent
65
+
66
+ # Check if parent exists and is writable
67
+ if parent_dir.exists():
68
+ # Try to create a test file in parent
69
+ test_file = parent_dir / ".ostruct_write_test"
70
+ try:
71
+ test_file.touch()
72
+ test_file.unlink()
73
+
74
+ # If download dir exists, check it specifically
75
+ if download_path.exists():
76
+ if not download_path.is_dir():
77
+ validation["issues"].append(
78
+ f"Path exists but is not a directory: {download_dir}"
79
+ )
80
+ else:
81
+ # Test write in actual directory
82
+ test_file = download_path / ".ostruct_write_test"
83
+ test_file.touch()
84
+ test_file.unlink()
85
+ validation["writable"] = True
86
+ else:
87
+ # Directory doesn't exist but parent is writable
88
+ validation["writable"] = True
89
+
90
+ except Exception as e:
91
+ validation["issues"].append(
92
+ f"Cannot write to directory: {e}"
93
+ )
94
+ else:
95
+ validation["issues"].append(
96
+ f"Parent directory does not exist: {parent_dir}"
97
+ )
98
+
99
+ except Exception as e:
100
+ validation["issues"].append(f"Error checking directory: {e}")
101
+
102
+ # Check for potential conflicts if expected files provided
103
+ if expected_files and validation["writable"]:
104
+ try:
105
+ download_path = Path(download_dir)
106
+ if download_path.exists():
107
+ existing_files = {
108
+ f.name for f in download_path.iterdir() if f.is_file()
109
+ }
110
+ conflicts = [
111
+ f for f in expected_files if f in existing_files
112
+ ]
113
+ validation["conflicts"] = conflicts
114
+ except Exception:
115
+ pass # Ignore errors in conflict detection
116
+
117
+ return validation
118
+
119
+ @staticmethod
120
+ def build_execution_plan(
121
+ processed_attachments: ProcessedAttachments,
122
+ template_path: str,
123
+ schema_path: str,
124
+ variables: Dict[str, Any],
125
+ security_mode: Optional[str] = None,
126
+ model: Optional[str] = None,
127
+ **kwargs: Any,
128
+ ) -> Dict[str, Any]:
129
+ """Build execution plan dict with consistent schema.
130
+
131
+ Args:
132
+ processed_attachments: Processed attachment specifications
133
+ template_path: Path to template file
134
+ schema_path: Path to schema file
135
+ variables: Template variables
136
+ security_mode: Security mode setting
137
+ model: Model to use for processing
138
+ **kwargs: Additional context (allowed_paths, cost_estimate, template_warning, etc.)
139
+
140
+ Returns:
141
+ Execution plan dictionary
142
+ """
143
+ # Handle template warning information
144
+ template_warning = kwargs.get("template_warning")
145
+ original_template_path = kwargs.get("original_template_path")
146
+
147
+ # Use original path if available, otherwise use the provided path
148
+ display_path = original_template_path or template_path
149
+
150
+ template_info = {
151
+ "path": display_path,
152
+ "exists": (
153
+ Path(display_path).exists() if display_path != "---" else True
154
+ ),
155
+ }
156
+
157
+ # Add warning information if present
158
+ if template_warning:
159
+ template_info["warning"] = template_warning
160
+
161
+ schema_info = {
162
+ "path": schema_path,
163
+ "exists": Path(schema_path).exists(),
164
+ }
165
+
166
+ # Build plan structure
167
+ plan = {
168
+ "schema_version": "1.0",
169
+ "type": "execution_plan",
170
+ "timestamp": datetime.now().isoformat(),
171
+ "template": template_info,
172
+ "schema": schema_info,
173
+ "model": model or "gpt-4o",
174
+ "variables": variables,
175
+ "security_mode": security_mode or "permissive",
176
+ "attachments": PlanAssembler._format_attachments(
177
+ processed_attachments
178
+ ),
179
+ }
180
+
181
+ # Add tools section if enabled tools are provided
182
+ enabled_tools = kwargs.get("enabled_tools")
183
+ if enabled_tools:
184
+ tools_dict = {}
185
+ for tool in enabled_tools:
186
+ # Map tool names to boolean values for plan display
187
+ if tool == "code-interpreter":
188
+ tools_dict["code_interpreter"] = True
189
+ elif tool == "file-search":
190
+ tools_dict["file_search"] = True
191
+ elif tool == "web-search":
192
+ tools_dict["web_search"] = True
193
+ elif tool == "mcp":
194
+ tools_dict["mcp"] = True
195
+ elif (
196
+ tool != "template"
197
+ ): # Skip template as it's not an external tool
198
+ tools_dict[tool] = True
199
+
200
+ if tools_dict:
201
+ plan["tools"] = tools_dict
202
+
203
+ # Add download validation for Code Interpreter
204
+ if enabled_tools and "code-interpreter" in enabled_tools:
205
+ # Get CI config if available
206
+ ci_config = kwargs.get("ci_config")
207
+ download_validation = (
208
+ PlanAssembler.validate_download_configuration(
209
+ enabled_tools=enabled_tools,
210
+ ci_config=ci_config,
211
+ expected_files=kwargs.get("expected_files"),
212
+ )
213
+ )
214
+
215
+ # Add to plan if there are issues or useful info
216
+ if download_validation["enabled"]:
217
+ plan["download_validation"] = download_validation
218
+
219
+ # Add optional fields
220
+ if kwargs.get("allowed_paths"):
221
+ plan["allowed_paths"] = kwargs["allowed_paths"]
222
+ if kwargs.get("cost_estimate"):
223
+ plan["cost_estimate"] = kwargs["cost_estimate"]
224
+
225
+ return plan
226
+
227
+ @staticmethod
228
+ def build_run_summary(
229
+ execution_plan: Dict[str, Any],
230
+ result: Optional[Dict[str, Any]] = None,
231
+ execution_time: Optional[float] = None,
232
+ **kwargs: Any,
233
+ ) -> Dict[str, Any]:
234
+ """Build run summary dict from execution plan and results.
235
+
236
+ Args:
237
+ execution_plan: Original execution plan
238
+ result: Execution results
239
+ execution_time: Time taken for execution
240
+ **kwargs: Additional summary data
241
+
242
+ Returns:
243
+ Dictionary with consistent run summary structure
244
+ """
245
+ summary = {
246
+ "schema_version": "1.0",
247
+ "type": "run_summary",
248
+ "timestamp": time.time(),
249
+ "execution_time": execution_time,
250
+ "success": kwargs.get("success", True),
251
+ "original_plan": execution_plan,
252
+ }
253
+
254
+ if result:
255
+ summary["result"] = result
256
+
257
+ if "error" in kwargs:
258
+ summary["error"] = kwargs["error"]
259
+ summary["success"] = False
260
+
261
+ if "cost_breakdown" in kwargs:
262
+ summary["cost_breakdown"] = kwargs["cost_breakdown"]
263
+
264
+ return summary
265
+
266
+ @staticmethod
267
+ def _format_attachments(
268
+ processed_attachments: ProcessedAttachments,
269
+ ) -> List[Dict[str, Any]]:
270
+ """Format processed attachments for plan output.
271
+
272
+ Args:
273
+ processed_attachments: Processed attachment specifications
274
+
275
+ Returns:
276
+ List of formatted attachment dictionaries
277
+ """
278
+ attachments = []
279
+
280
+ # Add all attachment types with consistent format
281
+ for alias, spec in processed_attachments.alias_map.items():
282
+ attachment = {
283
+ "alias": alias,
284
+ "path": spec.path,
285
+ "targets": sorted(
286
+ list(spec.targets)
287
+ ), # Ensure consistent ordering
288
+ "type": "file" if not spec.recursive else "directory",
289
+ "exists": os.path.exists(spec.path),
290
+ "recursive": spec.recursive,
291
+ "pattern": spec.pattern,
292
+ }
293
+
294
+ # Add metadata about where this attachment will be processed
295
+ tool_info = []
296
+ if "prompt" in spec.targets:
297
+ tool_info.append("template")
298
+ if "code-interpreter" in spec.targets or "ci" in spec.targets:
299
+ tool_info.append("code_interpreter")
300
+ if "file-search" in spec.targets or "fs" in spec.targets:
301
+ tool_info.append("file_search")
302
+
303
+ attachment["processing"] = tool_info
304
+ attachments.append(attachment)
305
+
306
+ return attachments
307
+
308
+ @staticmethod
309
+ def validate_plan(plan: Dict[str, Any]) -> bool:
310
+ """Validate plan structure for consistency.
311
+
312
+ Args:
313
+ plan: Plan dictionary to validate
314
+
315
+ Returns:
316
+ True if plan structure is valid
317
+
318
+ Raises:
319
+ ValueError: If plan structure is invalid
320
+ """
321
+ required_fields = ["schema_version", "type", "timestamp"]
322
+
323
+ for field in required_fields:
324
+ if field not in plan:
325
+ raise ValueError(f"Missing required field: {field}")
326
+
327
+ if plan["type"] not in ["execution_plan", "run_summary"]:
328
+ raise ValueError(f"Invalid plan type: {plan['type']}")
329
+
330
+ if plan["schema_version"] != "1.0":
331
+ raise ValueError(
332
+ f"Unsupported schema version: {plan['schema_version']}"
333
+ )
334
+
335
+ return True