ostruct-cli 0.8.29__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.
- 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.0.dist-info}/METADATA +230 -127
- ostruct_cli-1.0.0.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.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.0.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.0.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
|