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.
Files changed (49) 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 +157 -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 +175 -5
  12. ostruct/cli/explicit_file_processor.py +0 -15
  13. ostruct/cli/file_info.py +97 -15
  14. ostruct/cli/file_list.py +43 -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/validators.py +255 -54
  42. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/METADATA +231 -128
  43. ostruct_cli-1.0.1.dist-info/RECORD +80 -0
  44. ostruct/cli/commands/quick_ref.py +0 -54
  45. ostruct/cli/template_optimizer.py +0 -478
  46. ostruct_cli-0.8.29.dist-info/RECORD +0 -71
  47. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/LICENSE +0 -0
  48. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/WHEEL +0 -0
  49. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,448 @@
1
+ """Shared upload manager for multi-tool file sharing.
2
+
3
+ This module implements a centralized upload management system that ensures files
4
+ attached to multiple tools are uploaded only once and shared across all target tools.
5
+ This prevents redundant uploads and improves performance for multi-target attachments.
6
+ """
7
+
8
+ import logging
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Set, Tuple
12
+
13
+ from openai import AsyncOpenAI
14
+
15
+ from .attachment_processor import AttachmentSpec, ProcessedAttachments
16
+ from .errors import CLIError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class UploadRecord:
23
+ """Record of a file upload with status tracking."""
24
+
25
+ path: Path # Original file path
26
+ upload_id: Optional[str] = None # OpenAI file ID after upload
27
+ tools_pending: Set[str] = field(
28
+ default_factory=set
29
+ ) # Tools waiting for this file
30
+ tools_completed: Set[str] = field(
31
+ default_factory=set
32
+ ) # Tools that have received this file
33
+ file_size: Optional[int] = None # File size in bytes
34
+ file_hash: Optional[str] = None # File content hash for identity
35
+
36
+
37
+ class UploadError(CLIError):
38
+ """Error during file upload operations."""
39
+
40
+ def __init__(self, message: str, **kwargs: Any):
41
+ """Initialize upload error with appropriate exit code."""
42
+ from .exit_codes import ExitCode
43
+
44
+ super().__init__(message, exit_code=ExitCode.USAGE_ERROR, **kwargs)
45
+
46
+ def __str__(self) -> str:
47
+ """Custom string representation without error type prefix."""
48
+ # For upload errors, we want clean user-friendly messages
49
+ # without the [USAGE_ERROR] prefix since our messages are already formatted
50
+ return self.message
51
+
52
+
53
+ class SharedUploadManager:
54
+ """Manages file uploads across multiple tools to avoid duplicates.
55
+
56
+ This manager coordinates file uploads between Code Interpreter and File Search
57
+ tools, ensuring that files attached to multiple targets are uploaded only once
58
+ but shared across all requesting tools.
59
+ """
60
+
61
+ def __init__(self, client: AsyncOpenAI):
62
+ """Initialize the shared upload manager.
63
+
64
+ Args:
65
+ client: AsyncOpenAI client for file operations
66
+ """
67
+ self.client = client
68
+
69
+ # Map file identity -> upload record
70
+ self._uploads: Dict[Tuple[int, int], UploadRecord] = {}
71
+
72
+ # Queue of files needing upload for each tool
73
+ self._upload_queue: Dict[str, Set[Tuple[int, int]]] = {
74
+ "code-interpreter": set(),
75
+ "file-search": set(),
76
+ }
77
+
78
+ # Track all uploaded file IDs for cleanup
79
+ self._all_uploaded_ids: Set[str] = set()
80
+
81
+ def register_attachments(
82
+ self, processed_attachments: ProcessedAttachments
83
+ ) -> None:
84
+ """Register all attachments for upload management.
85
+
86
+ Args:
87
+ processed_attachments: Processed attachment specifications
88
+ """
89
+ logger.debug("Registering attachments for upload management")
90
+
91
+ # Register all attachment specs
92
+ for spec in processed_attachments.alias_map.values():
93
+ self._register_attachment_spec(spec)
94
+
95
+ logger.debug(
96
+ f"Registered {len(self._uploads)} unique files for upload, "
97
+ f"CI queue: {len(self._upload_queue['code-interpreter'])}, "
98
+ f"FS queue: {len(self._upload_queue['file-search'])}"
99
+ )
100
+
101
+ def _register_attachment_spec(self, spec: AttachmentSpec) -> None:
102
+ """Register a single attachment specification.
103
+
104
+ Args:
105
+ spec: Attachment specification to register
106
+ """
107
+ # Get file identity
108
+ file_id = self._get_file_identity(Path(spec.path))
109
+
110
+ # Create upload record if it doesn't exist
111
+ if file_id not in self._uploads:
112
+ self._uploads[file_id] = UploadRecord(
113
+ path=Path(spec.path),
114
+ tools_pending=set(),
115
+ tools_completed=set(),
116
+ )
117
+
118
+ # Handle directory vs file differently
119
+ if Path(spec.path).is_dir():
120
+ # For directories, we need to expand and register individual files
121
+ self._register_directory_files(spec, file_id)
122
+ else:
123
+ # For individual files, register with target tools
124
+ self._register_file_for_targets(file_id, spec.targets)
125
+
126
+ def _register_directory_files(
127
+ self, spec: AttachmentSpec, base_file_id: Tuple[int, int]
128
+ ) -> None:
129
+ """Register individual files from a directory attachment.
130
+
131
+ Args:
132
+ spec: Directory attachment specification
133
+ base_file_id: Base file identity for the directory
134
+ """
135
+ path = Path(spec.path)
136
+
137
+ # Expand directory to individual files
138
+ files = []
139
+ if spec.recursive:
140
+ if spec.pattern:
141
+ files = list(path.rglob(spec.pattern))
142
+ else:
143
+ files = [f for f in path.rglob("*") if f.is_file()]
144
+ else:
145
+ if spec.pattern:
146
+ files = list(path.glob(spec.pattern))
147
+ else:
148
+ files = [f for f in path.iterdir() if f.is_file()]
149
+
150
+ # Register each file individually
151
+ for file_path in files:
152
+ try:
153
+ file_id = self._get_file_identity(file_path)
154
+
155
+ if file_id not in self._uploads:
156
+ self._uploads[file_id] = UploadRecord(
157
+ path=file_path,
158
+ tools_pending=set(),
159
+ tools_completed=set(),
160
+ )
161
+
162
+ self._register_file_for_targets(file_id, spec.targets)
163
+
164
+ except Exception as e:
165
+ logger.warning(f"Could not register file {file_path}: {e}")
166
+
167
+ def _register_file_for_targets(
168
+ self, file_id: Tuple[int, int], targets: Set[str]
169
+ ) -> None:
170
+ """Register a file for specific target tools.
171
+
172
+ Args:
173
+ file_id: File identity tuple
174
+ targets: Set of target tool names
175
+ """
176
+ record = self._uploads[file_id]
177
+
178
+ # Add to upload queues for tools that need uploads
179
+ for target in targets:
180
+ if target == "code-interpreter":
181
+ record.tools_pending.add(target)
182
+ self._upload_queue[target].add(file_id)
183
+ elif target == "file-search":
184
+ record.tools_pending.add(target)
185
+ self._upload_queue[target].add(file_id)
186
+ # "prompt" target doesn't need uploads, just template access
187
+
188
+ def _get_file_identity(self, path: Path) -> Tuple[int, int]:
189
+ """Get unique identity for a file based on inode and device.
190
+
191
+ This uses the file's inode and device numbers to create a unique
192
+ identity that works across different path representations of the same file.
193
+
194
+ Args:
195
+ path: File path to get identity for
196
+
197
+ Returns:
198
+ Tuple of (device, inode) for file identity
199
+
200
+ Raises:
201
+ OSError: If file cannot be accessed
202
+ """
203
+ try:
204
+ stat = Path(path).stat()
205
+ return (stat.st_dev, stat.st_ino)
206
+ except OSError as e:
207
+ logger.error(f"Cannot get file identity for {path}: {e}")
208
+ raise
209
+
210
+ async def upload_for_tool(self, tool: str) -> Dict[str, str]:
211
+ """Upload all queued files for a specific tool.
212
+
213
+ Args:
214
+ tool: Tool name ("code-interpreter" or "file-search")
215
+
216
+ Returns:
217
+ Dictionary mapping file paths to upload IDs
218
+
219
+ Raises:
220
+ UploadError: If any uploads fail
221
+ """
222
+ if tool not in self._upload_queue:
223
+ raise ValueError(f"Unknown tool: {tool}")
224
+
225
+ logger.debug(f"Processing uploads for {tool}")
226
+ uploaded = {}
227
+ failed_uploads = []
228
+
229
+ for file_id in self._upload_queue[tool]:
230
+ record = self._uploads[file_id]
231
+
232
+ try:
233
+ if record.upload_id is None:
234
+ # First upload for this file
235
+ record.upload_id = await self._perform_upload(record.path)
236
+ self._all_uploaded_ids.add(record.upload_id)
237
+ logger.info(
238
+ f"Uploaded {record.path} -> {record.upload_id}"
239
+ )
240
+ else:
241
+ logger.debug(
242
+ f"Reusing upload {record.upload_id} for {record.path}"
243
+ )
244
+
245
+ uploaded[str(record.path)] = record.upload_id
246
+ record.tools_completed.add(tool)
247
+ record.tools_pending.discard(tool)
248
+
249
+ except UploadError as e:
250
+ # UploadError already has user-friendly message, don't duplicate logging
251
+ failed_uploads.append((record.path, str(e)))
252
+ except Exception as e:
253
+ logger.error(f"Failed to upload {record.path} for {tool}: {e}")
254
+ failed_uploads.append((record.path, str(e)))
255
+
256
+ if failed_uploads:
257
+ # If we have user-friendly error messages, present them cleanly
258
+ if len(failed_uploads) == 1:
259
+ # Single file failure - show the detailed error message
260
+ _, error_msg = failed_uploads[0]
261
+ raise UploadError(error_msg)
262
+ else:
263
+ # Multiple file failures - show summary with details
264
+ error_msg = (
265
+ f"Failed to upload {len(failed_uploads)} files:\n\n"
266
+ )
267
+ for i, (path, error) in enumerate(failed_uploads, 1):
268
+ error_msg += f"{i}. {error}\n\n"
269
+ raise UploadError(error_msg.rstrip())
270
+
271
+ logger.debug(f"Completed {len(uploaded)} uploads for {tool}")
272
+ return uploaded
273
+
274
+ async def _perform_upload(self, file_path: Path) -> str:
275
+ """Perform actual file upload with error handling.
276
+
277
+ Args:
278
+ file_path: Path to file to upload
279
+
280
+ Returns:
281
+ OpenAI file ID
282
+
283
+ Raises:
284
+ UploadError: If upload fails
285
+ """
286
+ try:
287
+ # Validate file exists and get info
288
+ if not file_path.exists():
289
+ raise FileNotFoundError(f"File not found: {file_path}")
290
+
291
+ file_size = file_path.stat().st_size
292
+ logger.debug(f"Uploading {file_path} ({file_size} bytes)")
293
+
294
+ # Perform upload
295
+ with open(file_path, "rb") as f:
296
+ upload_response = await self.client.files.create(
297
+ file=f,
298
+ purpose="assistants", # Standard purpose for both CI and FS
299
+ )
300
+
301
+ logger.debug(
302
+ f"Successfully uploaded {file_path} as {upload_response.id}"
303
+ )
304
+ return upload_response.id
305
+
306
+ except Exception as e:
307
+ # Parse OpenAI API errors for better user experience
308
+ # Note: We don't log here as the error will be handled at a higher level
309
+ user_friendly_error = self._parse_upload_error(file_path, e)
310
+ raise UploadError(user_friendly_error)
311
+
312
+ def _parse_upload_error(self, file_path: Path, error: Exception) -> str:
313
+ """Parse OpenAI upload errors into user-friendly messages.
314
+
315
+ Args:
316
+ file_path: Path to the file that failed to upload
317
+ error: The original exception from OpenAI API
318
+
319
+ Returns:
320
+ User-friendly error message with actionable suggestions
321
+ """
322
+ error_str = str(error)
323
+ file_ext = file_path.suffix.lower()
324
+
325
+ # Check for unsupported file extension error
326
+ if (
327
+ "Invalid extension" in error_str
328
+ and "Supported formats" in error_str
329
+ ):
330
+ return (
331
+ f"❌ Cannot upload {file_path.name} to Code Interpreter/File Search\n"
332
+ f" File extension '{file_ext}' is not supported by OpenAI's tools.\n"
333
+ f" See: https://platform.openai.com/docs/assistants/tools/code-interpreter#supported-files\n\n"
334
+ f"💡 Solutions:\n"
335
+ f" 1. Use template-only routing (recommended):\n"
336
+ f" Change: --dir ci:configs {file_path.parent}\n"
337
+ f" To: --dir configs {file_path.parent}\n"
338
+ f' Your template can still access content with: {{{{ file_ref("configs") }}}}\n\n'
339
+ f" 2. Rename file to add .txt extension:\n"
340
+ f" {file_path.name} → {file_path.name}.txt\n"
341
+ f" Then use: --dir ci:configs {file_path.parent}\n"
342
+ f" (OpenAI will treat it as text but preserve YAML content)"
343
+ )
344
+
345
+ # Check for file size errors
346
+ if (
347
+ "file too large" in error_str.lower()
348
+ or "size limit" in error_str.lower()
349
+ ):
350
+ return (
351
+ f"❌ File too large: {file_path.name}\n"
352
+ f" OpenAI tools have a file size limit (typically 100MB).\n\n"
353
+ f"💡 Solutions:\n"
354
+ f" 1. Use template-only routing: --dir configs {file_path.parent}\n"
355
+ f" 2. Split large files into smaller chunks\n"
356
+ f" 3. Use file summarization before upload"
357
+ )
358
+
359
+ # Generic upload error with helpful context
360
+ return (
361
+ f"❌ Failed to upload {file_path.name}\n"
362
+ f" {error_str}\n\n"
363
+ f"💡 If this is a configuration file, try template-only routing:\n"
364
+ f" Change: --dir ci:configs {file_path.parent}\n"
365
+ f" To: --dir configs {file_path.parent}"
366
+ )
367
+
368
+ def get_upload_summary(self) -> Dict[str, Any]:
369
+ """Get summary of upload operations.
370
+
371
+ Returns:
372
+ Dictionary with upload statistics and status
373
+ """
374
+ total_files = len(self._uploads)
375
+ uploaded_files = sum(
376
+ 1 for record in self._uploads.values() if record.upload_id
377
+ )
378
+ pending_files = total_files - uploaded_files
379
+
380
+ ci_files = len(self._upload_queue["code-interpreter"])
381
+ fs_files = len(self._upload_queue["file-search"])
382
+
383
+ return {
384
+ "total_files": total_files,
385
+ "uploaded_files": uploaded_files,
386
+ "pending_files": pending_files,
387
+ "code_interpreter_files": ci_files,
388
+ "file_search_files": fs_files,
389
+ "total_upload_ids": len(self._all_uploaded_ids),
390
+ "shared_files": total_files
391
+ - ci_files
392
+ - fs_files
393
+ + len(self._all_uploaded_ids), # Files used by multiple tools
394
+ }
395
+
396
+ async def cleanup_uploads(self) -> None:
397
+ """Clean up all uploaded files.
398
+
399
+ This method deletes all files that were uploaded during this session.
400
+ Should be called when the operation is complete to avoid leaving
401
+ temporary files in OpenAI storage.
402
+ """
403
+ if not self._all_uploaded_ids:
404
+ logger.debug("No uploaded files to clean up")
405
+ return
406
+
407
+ logger.debug(
408
+ f"Cleaning up {len(self._all_uploaded_ids)} uploaded files"
409
+ )
410
+ cleanup_errors = []
411
+
412
+ for file_id in self._all_uploaded_ids:
413
+ try:
414
+ await self.client.files.delete(file_id)
415
+ logger.debug(f"Deleted uploaded file: {file_id}")
416
+ except Exception as e:
417
+ logger.warning(
418
+ f"Failed to delete uploaded file {file_id}: {e}"
419
+ )
420
+ cleanup_errors.append((file_id, str(e)))
421
+
422
+ if cleanup_errors:
423
+ logger.error(f"Failed to clean up {len(cleanup_errors)} files")
424
+ else:
425
+ logger.debug("Successfully cleaned up all uploaded files")
426
+
427
+ # Clear tracking
428
+ self._all_uploaded_ids.clear()
429
+
430
+ def get_files_for_tool(self, tool: str) -> List[str]:
431
+ """Get list of upload IDs for a specific tool.
432
+
433
+ Args:
434
+ tool: Tool name ("code-interpreter" or "file-search")
435
+
436
+ Returns:
437
+ List of OpenAI file IDs for the tool
438
+ """
439
+ if tool not in self._upload_queue:
440
+ return []
441
+
442
+ file_ids = []
443
+ for file_id in self._upload_queue[tool]:
444
+ record = self._uploads[file_id]
445
+ if record.upload_id:
446
+ file_ids.append(record.upload_id)
447
+
448
+ return file_ids