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
@@ -14,6 +14,7 @@ from typing import (
14
14
  List,
15
15
  Optional,
16
16
  Sequence,
17
+ Set,
17
18
  TypeVar,
18
19
  Union,
19
20
  )
@@ -33,6 +34,192 @@ logger = logging.getLogger(__name__)
33
34
  T = TypeVar("T")
34
35
 
35
36
 
37
+ # ============================================================================
38
+ # Template Structure Enhancement System (TSES) Components
39
+ # ============================================================================
40
+
41
+
42
+ class TemplateStructureError(Exception):
43
+ """Exception for TSES-specific errors with helpful suggestions."""
44
+
45
+ def __init__(self, message: str, suggestions: Optional[List[str]] = None):
46
+ super().__init__(message)
47
+ self.suggestions = suggestions or []
48
+
49
+
50
+ def format_tses_error(error: TemplateStructureError) -> str:
51
+ """Format TSES error with suggestions."""
52
+ lines = [f"Template Structure Error: {error}"]
53
+ if error.suggestions:
54
+ lines.append("Suggestions:")
55
+ for suggestion in error.suggestions:
56
+ lines.append(f" • {suggestion}")
57
+ return "\n".join(lines)
58
+
59
+
60
+ class AliasManager:
61
+ """Manages file aliases and tracks references."""
62
+
63
+ def __init__(self) -> None:
64
+ self.aliases: Dict[str, Dict[str, Any]] = {}
65
+ self.referenced: Set[str] = set()
66
+
67
+ def register_attachment(
68
+ self,
69
+ alias: str,
70
+ path: str,
71
+ files: List[Any],
72
+ is_collection: bool = False,
73
+ ) -> None:
74
+ """Register files attached via CLI with their alias."""
75
+ # Determine attachment type from the files themselves
76
+ if is_collection:
77
+ attachment_type = "collection"
78
+ elif files:
79
+ # Use the attachment_type from the first file if available
80
+ first_file = files[0]
81
+ attachment_type = getattr(first_file, "attachment_type", "file")
82
+ else:
83
+ # Fallback for empty file lists
84
+ attachment_type = "file"
85
+
86
+ self.aliases[alias] = {
87
+ "type": attachment_type,
88
+ "path": path,
89
+ "files": files,
90
+ }
91
+
92
+ def reference_alias(self, alias: str) -> None:
93
+ """Mark an alias as referenced by file_ref()."""
94
+ if alias not in self.aliases:
95
+ available = list(self.aliases.keys())
96
+ raise TemplateStructureError(
97
+ f"Unknown alias '{alias}' in file_ref()",
98
+ [
99
+ f"Available aliases: {', '.join(available) if available else 'none'}",
100
+ "Check your --dir and --file attachments",
101
+ ],
102
+ )
103
+ self.referenced.add(alias)
104
+
105
+ def get_referenced_aliases(self) -> Dict[str, Dict[str, Any]]:
106
+ """Get only the aliases that were actually referenced."""
107
+ return {
108
+ alias: data
109
+ for alias, data in self.aliases.items()
110
+ if alias in self.referenced
111
+ }
112
+
113
+
114
+ class XMLAppendixBuilder:
115
+ """Builds XML appendix for referenced file aliases."""
116
+
117
+ def __init__(self, alias_manager: AliasManager) -> None:
118
+ self.alias_manager = alias_manager
119
+
120
+ def build_appendix(self) -> str:
121
+ """Build XML appendix for all referenced aliases."""
122
+ referenced = self.alias_manager.get_referenced_aliases()
123
+
124
+ if not referenced:
125
+ return ""
126
+
127
+ lines = ["<files>"]
128
+
129
+ for alias, data in referenced.items():
130
+ alias_type = data["type"]
131
+ path = data["path"]
132
+ files = data["files"]
133
+
134
+ if alias_type == "file":
135
+ # Single file
136
+ file_info = files[0]
137
+ lines.append(f' <file alias="{alias}" path="{path}">')
138
+ lines.append(
139
+ f" <content><![CDATA[{file_info.content}]]></content>"
140
+ )
141
+ lines.append(" </file>")
142
+
143
+ elif alias_type == "dir":
144
+ # Directory with multiple files
145
+ lines.append(f' <dir alias="{alias}" path="{path}">')
146
+ for file_info in files:
147
+ rel_path = getattr(
148
+ file_info, "relative_path", file_info.name
149
+ )
150
+ lines.append(f' <file path="{rel_path}">')
151
+ lines.append(
152
+ f" <content><![CDATA[{file_info.content}]]></content>"
153
+ )
154
+ lines.append(" </file>")
155
+ lines.append(" </dir>")
156
+
157
+ elif alias_type == "collection":
158
+ # File collection
159
+ lines.append(f' <collection alias="{alias}" path="{path}">')
160
+ for file_info in files:
161
+ file_path = getattr(file_info, "path", file_info.name)
162
+ lines.append(f' <file path="{file_path}">')
163
+ lines.append(
164
+ f" <content><![CDATA[{file_info.content}]]></content>"
165
+ )
166
+ lines.append(" </file>")
167
+ lines.append(" </collection>")
168
+
169
+ lines.append("</files>")
170
+ return "\n".join(lines)
171
+
172
+
173
+ # Global alias manager instance (set during environment creation)
174
+ _alias_manager: Optional[AliasManager] = None
175
+
176
+
177
+ def file_ref(alias_name: str) -> str:
178
+ """Reference a file collection by its CLI alias name.
179
+
180
+ Args:
181
+ alias_name: The alias from --dir or --file attachment
182
+
183
+ Returns:
184
+ Reference string that renders as <alias_name>
185
+
186
+ Usage:
187
+ {{ file_ref("source-code") }} -> renders as "<source-code>"
188
+ """
189
+ global _alias_manager
190
+
191
+ if not _alias_manager:
192
+ raise TemplateStructureError(
193
+ "File references not initialized",
194
+ [
195
+ "Check template processing pipeline",
196
+ "Ensure files are properly attached via CLI",
197
+ ],
198
+ )
199
+
200
+ # Register this alias as referenced
201
+ _alias_manager.reference_alias(alias_name)
202
+
203
+ # Return the reference format
204
+ return f"<{alias_name}>"
205
+
206
+
207
+ def register_tses_filters(
208
+ env: Environment, alias_manager: AliasManager
209
+ ) -> None:
210
+ """Register TSES functions in Jinja2 environment."""
211
+ global _alias_manager
212
+ _alias_manager = alias_manager
213
+
214
+ # Register file_ref as a global function
215
+ env.globals["file_ref"] = file_ref
216
+
217
+
218
+ # ============================================================================
219
+ # End TSES v2.0 Components
220
+ # ============================================================================
221
+
222
+
36
223
  def extract_keywords(text: str) -> List[str]:
37
224
  """Extract keywords from text."""
38
225
  return text.split()
@@ -50,12 +237,12 @@ def char_count(text: str) -> int:
50
237
 
51
238
  def to_json(obj: Any) -> str:
52
239
  """Convert object to JSON string."""
53
- return json.dumps(obj, indent=2)
240
+ return json.dumps(obj)
54
241
 
55
242
 
56
- def from_json(text: str) -> Any:
243
+ def from_json(json_str: str) -> Any:
57
244
  """Parse JSON string to object."""
58
- return json.loads(text)
245
+ return json.loads(json_str)
59
246
 
60
247
 
61
248
  def remove_comments(text: str) -> str:
@@ -159,6 +346,16 @@ def debug_print(x: Any) -> None:
159
346
  print(f"DEBUG: {x}")
160
347
 
161
348
 
349
+ def format_json(obj: Any) -> str:
350
+ """Format JSON with proper indentation."""
351
+ if isinstance(obj, str):
352
+ try:
353
+ obj = json.loads(obj)
354
+ except json.JSONDecodeError:
355
+ return str(obj)
356
+ return json.dumps(obj, indent=2, ensure_ascii=False)
357
+
358
+
162
359
  def type_of(x: Any) -> str:
163
360
  """Get type name of object."""
164
361
  return type(x).__name__
@@ -190,6 +387,156 @@ def format_error(e: Exception) -> str:
190
387
  return f"{type(e).__name__}: {str(e)}"
191
388
 
192
389
 
390
+ @pass_context
391
+ def safe_get(context: Any, *args: Any) -> Any:
392
+ """Safely get a nested attribute path, returning default if any part is undefined.
393
+
394
+ This function provides safe access to nested object attributes without raising
395
+ UndefinedError when intermediate objects don't exist.
396
+
397
+ Template Usage:
398
+ safe_get(path: str, default_value: Any = "") -> Any
399
+
400
+ Args:
401
+ path: Dot-separated path to the attribute (e.g., "transcript.content")
402
+ default_value: Value to return if path doesn't exist (default: "")
403
+
404
+ Returns:
405
+ The value at the path if it exists and is non-empty, otherwise default_value
406
+
407
+ Examples:
408
+ {{ safe_get("transcript.content", "No transcript available") }}
409
+ {{ safe_get("user.profile.bio", "No bio provided") }}
410
+ {{ safe_get("config.debug") }} # Uses empty string as default
411
+
412
+ Common Mistakes:
413
+ ❌ {{ safe_get(object, 'property', 'default') }} # Wrong: passing object
414
+ ✅ {{ safe_get('object.property', 'default') }} # Right: string path
415
+
416
+ ❌ {{ safe_get(user_data, 'name') }} # Wrong: object first
417
+ ✅ {{ safe_get('user_data.name') }} # Right: string path
418
+
419
+ Raises:
420
+ TemplateStructureError: If arguments are incorrect or malformed
421
+ """
422
+ # Import here to avoid circular imports
423
+
424
+ # Validate argument count
425
+ if len(args) < 1 or len(args) > 2:
426
+ raise TemplateStructureError(
427
+ f"safe_get() takes 1 or 2 arguments, got {len(args)}",
428
+ [
429
+ "Correct usage: safe_get('path.to.property', 'default_value')",
430
+ "Example: safe_get('user.name', 'Anonymous')",
431
+ f"You provided: {len(args)} arguments",
432
+ ],
433
+ )
434
+
435
+ # Extract arguments
436
+ path = args[0]
437
+ default_value = args[1] if len(args) > 1 else ""
438
+
439
+ # Validate path parameter type
440
+ if not isinstance(path, str):
441
+ # Provide helpful error message for common mistakes
442
+ path_type = type(path).__name__
443
+ if hasattr(path, "__class__") and hasattr(
444
+ path.__class__, "__module__"
445
+ ):
446
+ # For complex objects, show module.class
447
+ module_name = path.__class__.__module__
448
+ if module_name != "builtins":
449
+ path_type = f"{module_name}.{path.__class__.__name__}"
450
+
451
+ # Check for common mistake patterns
452
+ suggestions = [
453
+ "Use string path syntax: safe_get('object.property', 'default')",
454
+ f"You passed: {path_type} as first argument",
455
+ "WRONG: safe_get(object, 'property', 'default')",
456
+ "RIGHT: safe_get('object.property', 'default')",
457
+ ]
458
+
459
+ # Add specific suggestions based on the type
460
+ if hasattr(path, "name") or hasattr(path, "content"):
461
+ suggestions.append(
462
+ "For file objects, use: safe_get('filename.property', 'default')"
463
+ )
464
+ elif isinstance(path, (list, tuple)):
465
+ suggestions.append(
466
+ "For collections, iterate first: {% for item in collection %}"
467
+ )
468
+ elif hasattr(path, "__dict__"):
469
+ suggestions.append(
470
+ "For objects, use dot notation in quotes: safe_get('object.attribute', 'default')"
471
+ )
472
+
473
+ raise TemplateStructureError(
474
+ f"safe_get() expects a string path, got {path_type}", suggestions
475
+ )
476
+
477
+ # Validate path is not empty
478
+ if not path.strip():
479
+ raise TemplateStructureError(
480
+ "safe_get() path cannot be empty",
481
+ [
482
+ "Provide a valid dot-separated path like 'object.property'",
483
+ "Example: safe_get('user.name', 'Anonymous')",
484
+ ],
485
+ )
486
+
487
+ try:
488
+ # Split the path and traverse the object tree
489
+ parts = path.split(".")
490
+ current = context
491
+
492
+ # Start from the first part in the context
493
+ for i, part in enumerate(parts):
494
+ if i == 0:
495
+ # First part: look in the template context
496
+ if part in context:
497
+ current = context[part]
498
+ else:
499
+ return default_value
500
+ else:
501
+ # Subsequent parts: traverse the object
502
+ if hasattr(current, part):
503
+ current = getattr(current, part)
504
+ elif isinstance(current, dict) and part in current:
505
+ current = current[part]
506
+ else:
507
+ # Path doesn't exist, return default
508
+ return default_value
509
+
510
+ # Apply emptiness check to the final value
511
+ # Check for None
512
+ if current is None:
513
+ return default_value
514
+
515
+ # Check for empty string
516
+ if isinstance(current, str) and not current.strip():
517
+ return default_value
518
+
519
+ # Check for empty collections (list, dict, etc.)
520
+ if hasattr(current, "__len__") and len(current) == 0:
521
+ return default_value
522
+
523
+ # Return the value (preserving intentional falsy values like False or 0)
524
+ return current
525
+
526
+ except AttributeError as e:
527
+ # More specific error handling for attribute access issues
528
+ logger.debug(f"safe_get attribute error for path '{path}': {e}")
529
+ return default_value
530
+ except (KeyError, TypeError) as e:
531
+ # Handle other access issues
532
+ logger.debug(f"safe_get access error for path '{path}': {e}")
533
+ return default_value
534
+ except Exception as e:
535
+ # Catch any other unexpected errors but log them for debugging
536
+ logger.warning(f"Unexpected error in safe_get for path '{path}': {e}")
537
+ return default_value
538
+
539
+
193
540
  @pass_context
194
541
  def estimate_tokens(context: Any, text: str) -> int:
195
542
  """Estimate number of tokens in text."""
@@ -202,11 +549,6 @@ def estimate_tokens(context: Any, text: str) -> int:
202
549
  return len(str(text).split())
203
550
 
204
551
 
205
- def format_json(obj: Any) -> str:
206
- """Format JSON with indentation."""
207
- return json.dumps(obj, indent=2, default=str)
208
-
209
-
210
552
  def auto_table(data: Any) -> str:
211
553
  """Format data as table based on type."""
212
554
  if isinstance(data, dict):
@@ -641,6 +983,60 @@ def single_filter(value: Any) -> Any:
641
983
  return value
642
984
 
643
985
 
986
+ def files_filter(value: Any) -> List[Any]:
987
+ """Ensure a file-bearing value is iterable.
988
+
989
+ This filter implements the file-sequence protocol by ensuring that
990
+ any file-bearing value can be iterated over. Single files yield
991
+ themselves, while collections remain as-is.
992
+
993
+ Args:
994
+ value: A file-bearing value (FileInfo, FileInfoList, or other iterable)
995
+
996
+ Returns:
997
+ A list containing the file(s) for uniform iteration
998
+ """
999
+ # Handle strings and bytes specially - treat as single items, not character sequences
1000
+ if isinstance(value, (str, bytes)):
1001
+ return [value]
1002
+
1003
+ try:
1004
+ # If it's already iterable (but not string/bytes), convert to list
1005
+ return list(value)
1006
+ except TypeError:
1007
+ # If not iterable, wrap in a list
1008
+ return [value]
1009
+
1010
+
1011
+ def is_fileish(value: Any) -> bool:
1012
+ """Test if a value is file-like (iterable collection of file objects).
1013
+
1014
+ This test function helps templates identify file-bearing values
1015
+ that implement the file-sequence protocol.
1016
+
1017
+ Args:
1018
+ value: The value to test
1019
+
1020
+ Returns:
1021
+ True if the value is iterable and contains file-like objects
1022
+ """
1023
+ try:
1024
+ # Import here to avoid circular imports
1025
+ from .file_info import FileInfo
1026
+
1027
+ # Check if it's iterable
1028
+ if not hasattr(value, "__iter__"):
1029
+ return False
1030
+
1031
+ # Convert to list to check contents
1032
+ items = list(value)
1033
+
1034
+ # Check if all items are FileInfo objects
1035
+ return all(isinstance(item, FileInfo) for item in items)
1036
+ except (TypeError, ImportError):
1037
+ return False
1038
+
1039
+
644
1040
  def register_template_filters(env: Environment) -> None:
645
1041
  """Register all template filters with the Jinja2 environment.
646
1042
 
@@ -682,10 +1078,19 @@ def register_template_filters(env: Environment) -> None:
682
1078
  "auto_table": auto_table,
683
1079
  # Single item extraction
684
1080
  "single": single_filter,
1081
+ # File-sequence protocol support
1082
+ "files": files_filter,
685
1083
  }
686
1084
 
687
1085
  env.filters.update(filters)
688
1086
 
1087
+ # Add template tests
1088
+ tests = {
1089
+ "fileish": is_fileish,
1090
+ }
1091
+
1092
+ env.tests.update(tests)
1093
+
689
1094
  # Add template globals
690
1095
  env.globals.update(
691
1096
  {
@@ -703,5 +1108,7 @@ def register_template_filters(env: Environment) -> None:
703
1108
  "pivot_table": pivot_table,
704
1109
  # Table utilities
705
1110
  "auto_table": auto_table,
1111
+ # Safe access utilities
1112
+ "safe_get": safe_get,
706
1113
  }
707
1114
  )