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/__init__.py CHANGED
@@ -1,25 +1,13 @@
1
1
  """Command-line interface for making structured OpenAI API calls."""
2
2
 
3
3
  # Import modules for test mocking support
4
- from . import (
5
- config,
6
- mcp_integration,
7
- model_validation,
8
- runner,
9
- )
10
- from .cli import (
11
- ExitCode,
12
- create_cli,
13
- main,
14
- )
4
+ from . import config, mcp_integration, model_validation, runner
5
+ from .cli import ExitCode, create_cli, main
15
6
  from .path_utils import validate_path_mapping
16
7
  from .registry_updates import get_update_notification
17
8
  from .runner import OstructRunner
18
9
  from .template_processor import validate_task_template
19
- from .validators import (
20
- validate_schema_file,
21
- validate_variable_mapping,
22
- )
10
+ from .validators import validate_schema_file, validate_variable_mapping
23
11
 
24
12
  __all__ = [
25
13
  "ExitCode",
@@ -0,0 +1,455 @@
1
+ """Attachment processing system for the new CLI interface.
2
+
3
+ This module processes the new target/alias attachment system and converts it to
4
+ the existing file routing structure for backward compatibility.
5
+ """
6
+
7
+ import logging
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Set, Union
11
+
12
+ from .explicit_file_processor import ExplicitRouting, ProcessingResult
13
+ from .security import SecurityManager
14
+ from .types import CLIParams
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class AttachmentSpec:
21
+ """Specification for a single attachment with target and alias."""
22
+
23
+ alias: str # User-provided alias for the attachment
24
+ path: Union[str, Path] # File or directory path
25
+ targets: Set[
26
+ str
27
+ ] # Target tools: {"prompt", "code-interpreter", "file-search"}
28
+ recursive: bool = False # For directory attachments
29
+ pattern: Optional[str] = None # Glob pattern for directory attachments
30
+ from_collection: bool = (
31
+ False # Whether this came from a --collect filelist
32
+ )
33
+ collection_base_alias: Optional[str] = (
34
+ None # Original collection alias for TSES
35
+ )
36
+ attachment_type: str = (
37
+ "file" # Original attachment type: "file", "dir", or "collection"
38
+ )
39
+
40
+
41
+ @dataclass
42
+ class ProcessedAttachments:
43
+ """Processed attachments organized by target tool."""
44
+
45
+ # Files/dirs for template access (prompt target)
46
+ template_files: List[AttachmentSpec] = field(default_factory=list)
47
+ template_dirs: List[AttachmentSpec] = field(default_factory=list)
48
+
49
+ # Files/dirs for code interpreter uploads
50
+ ci_files: List[AttachmentSpec] = field(default_factory=list)
51
+ ci_dirs: List[AttachmentSpec] = field(default_factory=list)
52
+
53
+ # Files/dirs for file search uploads
54
+ fs_files: List[AttachmentSpec] = field(default_factory=list)
55
+ fs_dirs: List[AttachmentSpec] = field(default_factory=list)
56
+
57
+ # Mapping of aliases to their specs for template context
58
+ alias_map: Dict[str, AttachmentSpec] = field(default_factory=dict)
59
+
60
+
61
+ class AttachmentProcessor:
62
+ """Processes new attachment specifications into existing file routing."""
63
+
64
+ def __init__(self, security_manager: SecurityManager):
65
+ """Initialize the attachment processor.
66
+
67
+ Args:
68
+ security_manager: Security manager for file validation
69
+ """
70
+ self.security_manager = security_manager
71
+
72
+ def process_attachments(
73
+ self, attachments: List[Dict[str, Any]]
74
+ ) -> ProcessedAttachments:
75
+ """Process attachment specifications into organized structure.
76
+
77
+ Args:
78
+ attachments: List of attachment dictionaries from CLI parsing
79
+
80
+ Returns:
81
+ ProcessedAttachments with files organized by target tool
82
+
83
+ Raises:
84
+ ValueError: If attachment specifications are invalid
85
+ """
86
+ logger.debug("Processing %d attachments", len(attachments))
87
+ processed = ProcessedAttachments()
88
+
89
+ for attachment_dict in attachments:
90
+ # Handle filelist syntax: path can be ("@", "filelist.txt") tuple
91
+ path_value = attachment_dict["path"]
92
+ if (
93
+ isinstance(path_value, tuple)
94
+ and len(path_value) == 2
95
+ and path_value[0] == "@"
96
+ ):
97
+ # Process filelist collection
98
+ filelist_specs = self._process_filelist(
99
+ path_value[1], attachment_dict
100
+ )
101
+ for spec in filelist_specs:
102
+ processed.alias_map[spec.alias] = spec
103
+ self._route_attachment(spec, processed)
104
+ else:
105
+ # Regular file/directory attachment
106
+ spec = AttachmentSpec(
107
+ alias=attachment_dict["alias"],
108
+ path=Path(path_value),
109
+ targets=set(attachment_dict["targets"]),
110
+ recursive=attachment_dict.get("recursive", False),
111
+ pattern=attachment_dict.get("pattern"),
112
+ attachment_type=attachment_dict.get(
113
+ "attachment_type", "file"
114
+ ),
115
+ )
116
+
117
+ # Validate file/directory with security manager
118
+ validated_path = self.security_manager.validate_file_access(
119
+ spec.path, context=f"attachment {spec.alias}"
120
+ )
121
+ spec.path = validated_path
122
+
123
+ # Add to alias map
124
+ processed.alias_map[spec.alias] = spec
125
+
126
+ # Route to appropriate target collections
127
+ self._route_attachment(spec, processed)
128
+
129
+ logger.debug(
130
+ "Processed attachments: %d template files, %d template dirs, "
131
+ "%d CI files, %d CI dirs, %d FS files, %d FS dirs",
132
+ len(processed.template_files),
133
+ len(processed.template_dirs),
134
+ len(processed.ci_files),
135
+ len(processed.ci_dirs),
136
+ len(processed.fs_files),
137
+ len(processed.fs_dirs),
138
+ )
139
+
140
+ return processed
141
+
142
+ def _route_attachment(
143
+ self, spec: AttachmentSpec, processed: ProcessedAttachments
144
+ ) -> None:
145
+ """Route a single attachment to appropriate target collections.
146
+
147
+ Args:
148
+ spec: Attachment specification to route
149
+ processed: ProcessedAttachments to update
150
+ """
151
+ is_dir = Path(spec.path).is_dir()
152
+
153
+ for target in spec.targets:
154
+ if target == "prompt":
155
+ if is_dir:
156
+ processed.template_dirs.append(spec)
157
+ else:
158
+ processed.template_files.append(spec)
159
+ elif target == "code-interpreter":
160
+ if is_dir:
161
+ processed.ci_dirs.append(spec)
162
+ else:
163
+ processed.ci_files.append(spec)
164
+ elif target == "file-search":
165
+ if is_dir:
166
+ processed.fs_dirs.append(spec)
167
+ else:
168
+ processed.fs_files.append(spec)
169
+ else:
170
+ logger.warning(
171
+ "Unknown target '%s' for attachment %s", target, spec.alias
172
+ )
173
+
174
+ def _process_filelist(
175
+ self, filelist_path: str, attachment_dict: Dict[str, Any]
176
+ ) -> List[AttachmentSpec]:
177
+ """Process @filelist.txt syntax for file collections.
178
+
179
+ Args:
180
+ filelist_path: Path to the filelist file
181
+ attachment_dict: Original attachment specification
182
+
183
+ Returns:
184
+ List of AttachmentSpec objects for each file in the list
185
+
186
+ Raises:
187
+ ValueError: If filelist file is invalid or files cannot be processed
188
+ """
189
+ logger.debug("Processing filelist: %s", filelist_path)
190
+
191
+ # Validate filelist file itself
192
+ list_file = Path(filelist_path)
193
+ validated_list_file = self.security_manager.validate_file_access(
194
+ list_file, context=f"filelist for {attachment_dict['alias']}"
195
+ )
196
+
197
+ specs = []
198
+ base_alias = attachment_dict["alias"]
199
+ targets = set(attachment_dict["targets"])
200
+
201
+ try:
202
+ with open(validated_list_file, "r", encoding="utf-8") as f:
203
+ for line_num, line in enumerate(f, 1):
204
+ line = line.strip()
205
+
206
+ # Skip empty lines and comments
207
+ if not line or line.startswith("#"):
208
+ continue
209
+
210
+ try:
211
+ # Resolve relative paths relative to filelist directory
212
+ file_path = Path(line)
213
+ if not file_path.is_absolute():
214
+ file_path = validated_list_file.parent / file_path
215
+
216
+ # Validate each file through security manager
217
+ validated_file = (
218
+ self.security_manager.validate_file_access(
219
+ file_path,
220
+ context=f"filelist {filelist_path}:{line_num}",
221
+ )
222
+ )
223
+
224
+ # Create unique alias for each file in collection
225
+ # Use base_alias with file index or filename
226
+ file_alias = f"{base_alias}_{line_num}"
227
+
228
+ # Create AttachmentSpec for each file
229
+ spec = AttachmentSpec(
230
+ alias=file_alias,
231
+ path=validated_file,
232
+ targets=targets,
233
+ recursive=False, # Files from list are individual files
234
+ pattern=None,
235
+ from_collection=True, # Mark as from collection
236
+ collection_base_alias=base_alias, # Store original alias
237
+ attachment_type="collection", # From --collect
238
+ )
239
+
240
+ specs.append(spec)
241
+ logger.debug(
242
+ "Added file from filelist: %s -> %s",
243
+ validated_file,
244
+ file_alias,
245
+ )
246
+
247
+ except Exception as e:
248
+ logger.warning(
249
+ "Filelist %s:%d: Failed to process '%s': %s",
250
+ filelist_path,
251
+ line_num,
252
+ line,
253
+ e,
254
+ )
255
+ # Continue processing other files in permissive modes
256
+ if hasattr(self.security_manager, "security_mode"):
257
+ from .security.types import PathSecurity
258
+
259
+ if (
260
+ getattr(
261
+ self.security_manager,
262
+ "security_mode",
263
+ None,
264
+ )
265
+ == PathSecurity.STRICT
266
+ ):
267
+ raise ValueError(
268
+ f"Filelist processing failed at line {line_num}: {e}"
269
+ )
270
+
271
+ except IOError as e:
272
+ logger.error("Failed to read filelist %s: %s", filelist_path, e)
273
+ raise ValueError(f"Cannot read filelist {filelist_path}: {e}")
274
+
275
+ logger.debug(
276
+ "Processed filelist %s: %d files loaded", filelist_path, len(specs)
277
+ )
278
+
279
+ return specs
280
+
281
+ def convert_to_explicit_routing(
282
+ self, processed: ProcessedAttachments
283
+ ) -> ExplicitRouting:
284
+ """Convert ProcessedAttachments to legacy ExplicitRouting format.
285
+
286
+ Args:
287
+ processed: ProcessedAttachments to convert
288
+
289
+ Returns:
290
+ ExplicitRouting compatible with existing file processor
291
+ """
292
+ routing = ExplicitRouting()
293
+
294
+ # Convert template attachments
295
+ routing.template_files = [
296
+ str(spec.path) for spec in processed.template_files
297
+ ]
298
+ routing.template_dirs = [
299
+ str(spec.path) for spec in processed.template_dirs
300
+ ]
301
+ routing.template_dir_aliases = [
302
+ (spec.alias, str(spec.path)) for spec in processed.template_dirs
303
+ ]
304
+
305
+ # Convert code interpreter attachments
306
+ routing.code_interpreter_files = [
307
+ str(spec.path) for spec in processed.ci_files
308
+ ]
309
+ routing.code_interpreter_dirs = [
310
+ str(spec.path) for spec in processed.ci_dirs
311
+ ]
312
+ routing.code_interpreter_dir_aliases = [
313
+ (spec.alias, str(spec.path)) for spec in processed.ci_dirs
314
+ ]
315
+
316
+ # Convert file search attachments
317
+ routing.file_search_files = [
318
+ str(spec.path) for spec in processed.fs_files
319
+ ]
320
+ routing.file_search_dirs = [
321
+ str(spec.path) for spec in processed.fs_dirs
322
+ ]
323
+ routing.file_search_dir_aliases = [
324
+ (spec.alias, str(spec.path)) for spec in processed.fs_dirs
325
+ ]
326
+
327
+ return routing
328
+
329
+ def create_processing_result(
330
+ self, processed: ProcessedAttachments
331
+ ) -> ProcessingResult:
332
+ """Create ProcessingResult from ProcessedAttachments.
333
+
334
+ Args:
335
+ processed: ProcessedAttachments to convert
336
+
337
+ Returns:
338
+ ProcessingResult compatible with existing pipeline
339
+ """
340
+ routing = self.convert_to_explicit_routing(processed)
341
+
342
+ # Determine enabled tools
343
+ enabled_tools = set()
344
+ if processed.template_files or processed.template_dirs:
345
+ enabled_tools.add("template")
346
+ if processed.ci_files or processed.ci_dirs:
347
+ enabled_tools.add("code-interpreter")
348
+ if processed.fs_files or processed.fs_dirs:
349
+ enabled_tools.add("file-search")
350
+
351
+ # Create validated files mapping
352
+ validated_files = {
353
+ "template": routing.template_files + routing.template_dirs,
354
+ "code-interpreter": routing.code_interpreter_files
355
+ + routing.code_interpreter_dirs,
356
+ "file-search": routing.file_search_files
357
+ + routing.file_search_dirs,
358
+ }
359
+
360
+ return ProcessingResult(
361
+ routing=routing,
362
+ enabled_tools=enabled_tools,
363
+ validated_files=validated_files,
364
+ auto_enabled_feedback=None, # New system doesn't auto-enable
365
+ )
366
+
367
+
368
+ def process_new_attachments(
369
+ args: CLIParams, security_manager: SecurityManager
370
+ ) -> Optional[ProcessingResult]:
371
+ """Process new-style attachment arguments into file routing.
372
+
373
+ This function checks for new attachment syntax in CLI args and processes
374
+ them into the existing file routing system for backward compatibility.
375
+
376
+ Args:
377
+ args: CLI parameters (may contain new attachment syntax)
378
+ security_manager: Security manager for file validation
379
+
380
+ Returns:
381
+ ProcessingResult if new attachments found, None otherwise
382
+ """
383
+ # Check if args contain new attachment syntax
384
+ if not _has_new_attachment_syntax(args):
385
+ return None
386
+
387
+ logger.debug("Detected new attachment syntax, processing...")
388
+ processor = AttachmentProcessor(security_manager)
389
+
390
+ # Extract attachment specifications from args
391
+ attachments = _extract_attachments_from_args(args)
392
+
393
+ # Process attachments
394
+ processed = processor.process_attachments(attachments)
395
+
396
+ # Convert to processing result
397
+ result = processor.create_processing_result(processed)
398
+
399
+ logger.debug("New attachment processing complete")
400
+ return result
401
+
402
+
403
+ def _has_new_attachment_syntax(args: CLIParams) -> bool:
404
+ """Check if CLI args contain new attachment syntax.
405
+
406
+ Args:
407
+ args: CLI parameters to check
408
+
409
+ Returns:
410
+ True if new attachment syntax is present
411
+ """
412
+ # Check for new attachment-related keys
413
+ new_syntax_keys = ["attaches", "dirs", "collects"]
414
+ return any(args.get(key) for key in new_syntax_keys)
415
+
416
+
417
+ def _extract_attachments_from_args(args: CLIParams) -> List[Dict[str, Any]]:
418
+ """Extract attachment specifications from CLI args.
419
+
420
+ Args:
421
+ args: CLI parameters containing attachment specifications
422
+
423
+ Returns:
424
+ List of attachment dictionaries with attachment_type added
425
+ """
426
+ attachments: List[Dict[str, Any]] = []
427
+
428
+ # Extract --attach specifications (file attachments)
429
+ attaches = args.get("attaches", [])
430
+ if attaches:
431
+ for attach in attaches:
432
+ attach_with_type = dict(attach)
433
+ attach_with_type["attachment_type"] = "file"
434
+ attachments.append(attach_with_type)
435
+
436
+ # Extract --dir specifications (directory attachments)
437
+ dirs = args.get("dirs", [])
438
+ if dirs:
439
+ for dir_spec in dirs:
440
+ dir_with_type = dict(dir_spec)
441
+ dir_with_type["attachment_type"] = "dir"
442
+ attachments.append(dir_with_type)
443
+
444
+ # Extract --collect specifications (collection attachments)
445
+ collects = args.get("collects", [])
446
+ if collects:
447
+ for collect in collects:
448
+ collect_with_type = dict(collect)
449
+ collect_with_type["attachment_type"] = "collection"
450
+ attachments.append(collect_with_type)
451
+
452
+ logger.debug(
453
+ "Extracted %d attachment specifications from args", len(attachments)
454
+ )
455
+ return attachments