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.
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.0.dist-info}/METADATA +230 -127
  43. ostruct_cli-1.0.0.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.0.dist-info}/LICENSE +0 -0
  48. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.0.dist-info}/WHEEL +0 -0
  49. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/validators.py CHANGED
@@ -9,7 +9,9 @@ from typing import Any, Dict, List, Optional, Tuple, Union
9
9
  import click
10
10
  import jinja2
11
11
 
12
+ from .constants import DefaultPaths
12
13
  from .errors import (
14
+ CLIError,
13
15
  DirectoryNotFoundError,
14
16
  InvalidJSONError,
15
17
  SchemaFileError,
@@ -17,7 +19,7 @@ from .errors import (
17
19
  VariableNameError,
18
20
  VariableValueError,
19
21
  )
20
- from .explicit_file_processor import ExplicitFileProcessor
22
+ from .exit_codes import ExitCode
21
23
  from .security import SecurityManager
22
24
  from .template_env import create_jinja_env
23
25
  from .template_processor import (
@@ -34,35 +36,6 @@ logger = logging.getLogger(__name__)
34
36
  FileRoutingResult = List[Tuple[Optional[str], Union[str, Path]]]
35
37
 
36
38
 
37
- def validate_name_path_pair(
38
- ctx: click.Context,
39
- param: click.Parameter,
40
- value: List[Tuple[str, Union[str, Path]]],
41
- ) -> List[Tuple[str, Union[str, Path]]]:
42
- """Validate name/path pairs for files and directories.
43
-
44
- Args:
45
- ctx: Click context
46
- param: Click parameter
47
- value: List of (name, path) tuples
48
-
49
- Returns:
50
- List of validated (name, Path) tuples
51
-
52
- Raises:
53
- click.BadParameter: If validation fails
54
- """
55
- if not value:
56
- return value
57
-
58
- result: List[Tuple[str, Union[str, Path]]] = []
59
- for name, path in value:
60
- if not name.isidentifier():
61
- raise click.BadParameter(f"Invalid variable name: {name}")
62
- result.append((name, Path(path)))
63
- return result
64
-
65
-
66
39
  def validate_file_routing_spec(
67
40
  ctx: click.Context,
68
41
  param: click.Parameter,
@@ -70,23 +43,23 @@ def validate_file_routing_spec(
70
43
  ) -> FileRoutingResult:
71
44
  """Validate file routing specifications supporting multiple syntaxes.
72
45
 
73
- Supports two syntaxes currently:
74
- - Simple path: -ft config.yaml (auto-generates variable name)
75
- - Equals syntax: -ft code_file=src/main.py (custom variable name)
46
+ Supports two syntaxes currently:
47
+ - Simple path: --file config.yaml (auto-generates variable name)
48
+ - Equals syntax: --file code_file=src/main.py (custom variable name)
76
49
 
77
- Note: Two-argument syntax (-ft name path) requires special handling at the CLI level
78
- and is not supported in this validator due to Click's argument processing.
50
+ Note: Two-argument syntax (--file name path) requires special handling at the CLI level
51
+ and is not supported in this validator due to Click's argument processing.
79
52
 
80
- Args:
81
- ctx: Click context
82
- param: Click parameter
83
- value: List of file specifications from multiple options
53
+ Args:
54
+ ctx: Click context
55
+ param: Click parameter
56
+ value: List of file specifications from multiple options
84
57
 
85
- Returns:
86
- List of (variable_name, path) tuples where variable_name=None means auto-generate
58
+ Returns:
59
+ List of (variable_name, path) tuples where variable_name=None means auto-generate
87
60
 
88
- Raises:
89
- click.BadParameter: If validation fails
61
+ Raises:
62
+ click.BadParameter: If validation fails
90
63
  """
91
64
  if not value:
92
65
  return []
@@ -95,7 +68,7 @@ def validate_file_routing_spec(
95
68
 
96
69
  for spec in value:
97
70
  if "=" in spec:
98
- # Equals syntax: -ft code_file=src/main.py
71
+ # Equals syntax: --file code_file=src/main.py
99
72
  if spec.count("=") != 1:
100
73
  raise click.BadParameter(
101
74
  f"Invalid format '{spec}'. Use name=path or just path."
@@ -113,7 +86,7 @@ def validate_file_routing_spec(
113
86
  raise click.BadParameter(f"File not found: {path}")
114
87
  result.append((name, Path(path)))
115
88
  else:
116
- # Simple path: -ft config.yaml
89
+ # Simple path: --file config.yaml
117
90
  path = spec.strip()
118
91
  if not path:
119
92
  raise click.BadParameter("Empty file path")
@@ -193,7 +166,7 @@ def validate_json_variable(
193
166
 
194
167
  if "=" not in var:
195
168
  raise InvalidJSONError(
196
- f"JSON variable must be in format name='{'json':\"value\"}': {var}"
169
+ f'JSON variable must be in format name={{"json":"value"}}: {var}'
197
170
  )
198
171
  name, json_str = var.split("=", 1)
199
172
  name = name.strip()
@@ -389,6 +362,9 @@ def validate_security_manager(
389
362
  base_dir: Optional[str] = None,
390
363
  allowed_dirs: Optional[List[str]] = None,
391
364
  allowed_dir_file: Optional[str] = None,
365
+ allow_files: Optional[List[str]] = None,
366
+ allow_lists: Optional[List[str]] = None,
367
+ security_mode: str = "warn",
392
368
  ) -> SecurityManager:
393
369
  """Validate and create security manager.
394
370
 
@@ -396,6 +372,9 @@ def validate_security_manager(
396
372
  base_dir: Base directory for file access. Defaults to current working directory.
397
373
  allowed_dirs: Optional list of additional allowed directories
398
374
  allowed_dir_file: Optional file containing allowed directories
375
+ allow_files: Optional list of individual files to allow (tracked by inode)
376
+ allow_lists: Optional list of allow-list files to load
377
+ security_mode: Path security mode (permissive/warn/strict)
399
378
 
400
379
  Returns:
401
380
  Configured SecurityManager instance
@@ -408,8 +387,22 @@ def validate_security_manager(
408
387
  if base_dir is None:
409
388
  base_dir = os.getcwd()
410
389
 
411
- # Create security manager with base directory
412
- security_manager = SecurityManager(base_dir)
390
+ # Parse security mode
391
+ from .security.types import PathSecurity
392
+
393
+ # Ensure security_mode is not None (safety check)
394
+ if security_mode is None:
395
+ security_mode = "warn"
396
+
397
+ try:
398
+ parsed_security_mode = PathSecurity(security_mode.lower())
399
+ except ValueError:
400
+ parsed_security_mode = PathSecurity.WARN
401
+
402
+ # Create security manager with base directory and security mode
403
+ security_manager = SecurityManager(
404
+ base_dir, security_mode=parsed_security_mode
405
+ )
413
406
 
414
407
  # Add explicitly allowed directories
415
408
  if allowed_dirs:
@@ -429,9 +422,114 @@ def validate_security_manager(
429
422
  f"Failed to read allowed directories file: {e}"
430
423
  )
431
424
 
425
+ # Configure enhanced security features (allow_files and allow_lists)
426
+ if allow_files or allow_lists:
427
+ security_manager.configure_security_mode(
428
+ mode=parsed_security_mode,
429
+ allow_files=allow_files,
430
+ allow_lists=allow_lists,
431
+ )
432
+
432
433
  return security_manager
433
434
 
434
435
 
436
+ def validate_download_configuration(args: CLIParams) -> None:
437
+ """Validate download configuration without creating directories.
438
+
439
+ This function validates that the download directory can be written to
440
+ when Code Interpreter is being used, without actually creating the directory.
441
+
442
+ Args:
443
+ args: CLI parameters
444
+
445
+ Raises:
446
+ CLIError: If download directory validation fails
447
+ """
448
+ logger.debug("=== Download Directory Validation ===")
449
+
450
+ # Check if Code Interpreter is being used
451
+ routing_result = args.get("_routing_result")
452
+ logger.debug(f"Routing result: {routing_result}")
453
+
454
+ if not routing_result or not hasattr(routing_result, "enabled_tools"):
455
+ logger.debug(
456
+ "No routing result available, skipping download validation"
457
+ )
458
+ return # No routing result available, skip validation
459
+
460
+ logger.debug(f"Enabled tools: {routing_result.enabled_tools}")
461
+
462
+ if "code-interpreter" not in routing_result.enabled_tools:
463
+ logger.debug(
464
+ "Code Interpreter not being used, skipping download validation"
465
+ )
466
+ return # Code Interpreter not being used, skip validation
467
+
468
+ # Get resolved download directory using same logic as runner.py
469
+ from typing import Union, cast
470
+
471
+ from .config import OstructConfig
472
+
473
+ config_path = cast(Union[str, Path, None], args.get("config"))
474
+ config = OstructConfig.load(config_path)
475
+ ci_config = config.get_code_interpreter_config()
476
+
477
+ download_dir = (
478
+ args.get("ci_download_dir") # CLI flag has highest priority
479
+ or ci_config.get("output_directory") # Config file is second
480
+ or DefaultPaths.CODE_INTERPRETER_OUTPUT_DIR # Default is last
481
+ )
482
+
483
+ logger.debug(f"Validating download directory: {download_dir}")
484
+
485
+ # Convert to Path object
486
+ download_path = Path(download_dir)
487
+
488
+ # Validate parent directory exists and is writable
489
+ parent_dir = download_path.parent
490
+ if not parent_dir.exists():
491
+ raise CLIError(
492
+ f"Parent directory does not exist: {parent_dir}\n"
493
+ f"💡 Try:\n"
494
+ f" • Create parent directory: mkdir -p {parent_dir}\n"
495
+ f" • Use different directory: --ci-download-dir ./my-downloads\n"
496
+ f" • Check current directory: pwd",
497
+ exit_code=ExitCode.USAGE_ERROR,
498
+ )
499
+
500
+ if not os.access(parent_dir, os.W_OK):
501
+ raise CLIError(
502
+ f"No write permission to parent directory: {parent_dir}\n"
503
+ f"💡 Try:\n"
504
+ f" • Check directory permissions: ls -la {parent_dir}\n"
505
+ f" • Fix permissions: chmod u+w {parent_dir}\n"
506
+ f" • Use different directory: --ci-download-dir ./my-downloads",
507
+ exit_code=ExitCode.USAGE_ERROR,
508
+ )
509
+
510
+ # Check if download directory exists and is writable (if it exists)
511
+ if download_path.exists():
512
+ if not download_path.is_dir():
513
+ raise CLIError(
514
+ f"Download path exists but is not a directory: {download_path}\n"
515
+ f"💡 Try:\n"
516
+ f" • Remove the file: rm {download_path}\n"
517
+ f" • Use different directory: --ci-download-dir ./my-downloads",
518
+ exit_code=ExitCode.USAGE_ERROR,
519
+ )
520
+
521
+ if not os.access(download_path, os.W_OK):
522
+ raise CLIError(
523
+ f"No write permission to download directory: {download_path}\n"
524
+ f"💡 Try:\n"
525
+ f" • Fix permissions: chmod u+w {download_path}\n"
526
+ f" • Use different directory: --ci-download-dir ./my-downloads",
527
+ exit_code=ExitCode.USAGE_ERROR,
528
+ )
529
+
530
+ logger.debug(f"Download directory validation passed: {download_dir}")
531
+
532
+
435
533
  async def validate_inputs(
436
534
  args: CLIParams,
437
535
  ) -> Tuple[
@@ -461,16 +559,37 @@ async def validate_inputs(
461
559
  SchemaValidationError: When schema is invalid
462
560
  """
463
561
  logger.debug("=== Input Validation Phase ===")
562
+ logger.debug(f"validate_inputs called with args keys: {list(args.keys())}")
464
563
  security_manager = validate_security_manager(
465
564
  base_dir=args.get("base_dir"),
466
- allowed_dirs=args.get("allowed_dirs"),
565
+ allowed_dirs=args.get("allow_dir"), # type: ignore[arg-type]
467
566
  allowed_dir_file=args.get("allowed_dir_file"),
567
+ allow_files=args.get("allow_file"), # type: ignore[arg-type]
568
+ allow_lists=args.get("allow_list"), # type: ignore[arg-type]
569
+ security_mode=args.get("path_security", "warn"), # type: ignore[arg-type]
468
570
  )
469
571
 
470
- # Process explicit file routing (T2.4)
471
- logger.debug("Processing explicit file routing")
472
- file_processor = ExplicitFileProcessor(security_manager)
473
- routing_result = await file_processor.process_file_routing(args) # type: ignore[arg-type]
572
+ # Process file routing using new attachment system only (T3.0)
573
+ logger.debug("Processing file routing with new attachment system")
574
+
575
+ from .attachment_processor import process_new_attachments
576
+
577
+ routing_result = process_new_attachments(args, security_manager)
578
+
579
+ # The new system is mandatory - no fallback to legacy file routing
580
+ if routing_result is None:
581
+ # Create empty routing result if no attachments specified
582
+ from .explicit_file_processor import ExplicitRouting, ProcessingResult
583
+
584
+ routing_result = ProcessingResult(
585
+ routing=ExplicitRouting(),
586
+ enabled_tools=set(),
587
+ validated_files={},
588
+ auto_enabled_feedback=None,
589
+ )
590
+ logger.debug("No attachments specified - created empty routing result")
591
+ else:
592
+ logger.debug("Processed new attachment system")
474
593
 
475
594
  # Display auto-enablement feedback to user
476
595
  if routing_result.auto_enabled_feedback:
@@ -479,6 +598,11 @@ async def validate_inputs(
479
598
  # Store routing result in args for use by tool processors
480
599
  args["_routing_result"] = routing_result # type: ignore[typeddict-unknown-key]
481
600
 
601
+ # Validate download directory configuration (after routing is processed)
602
+ logger.debug("About to call validate_download_configuration")
603
+ validate_download_configuration(args)
604
+ logger.debug("Finished validate_download_configuration")
605
+
482
606
  task_template = validate_task_template(
483
607
  args.get("task"), args.get("task_file")
484
608
  )
@@ -501,7 +625,27 @@ async def validate_inputs(
501
625
  template_context = await create_template_context_from_routing(
502
626
  args, security_manager, routing_result
503
627
  )
504
- env = create_jinja_env()
628
+
629
+ # Extract files from template context for file reference support
630
+ files = []
631
+ for key, value in template_context.items():
632
+ if hasattr(value, "__iter__") and not isinstance(value, (str, dict)):
633
+ # Check if this is a list of FileInfo objects
634
+ try:
635
+ for item in value:
636
+ if hasattr(
637
+ item, "parent_alias"
638
+ ): # FileInfo with TSES fields
639
+ files.append(item)
640
+ except (TypeError, AttributeError):
641
+ continue
642
+
643
+ # Create environment with file reference support
644
+ env, alias_manager = create_jinja_env(files=files)
645
+
646
+ # Store alias manager in args for use by template processor if it has any aliases
647
+ if alias_manager.aliases:
648
+ args["_alias_manager"] = alias_manager # type: ignore[typeddict-unknown-key]
505
649
 
506
650
  return (
507
651
  security_manager,
@@ -511,3 +655,60 @@ async def validate_inputs(
511
655
  env,
512
656
  args.get("task_file"),
513
657
  )
658
+
659
+
660
+ def parse_file_path_spec(
661
+ ctx: click.Context,
662
+ param: click.Parameter,
663
+ value: str,
664
+ ) -> Optional[Tuple[Optional[str], str]]:
665
+ """Parse file path specification with optional alias.
666
+
667
+ Supports multiple formats:
668
+ - Simple path: --file config.yaml (auto-generates variable name)
669
+ - Equals syntax: --file code_file=src/main.py (custom variable name)
670
+
671
+ Note: Two-argument syntax (--file name path) requires special handling at the CLI level
672
+ and is not processed by this validator.
673
+
674
+ Args:
675
+ ctx: Click context
676
+ param: Click parameter
677
+ value: Raw value from CLI
678
+
679
+ Returns:
680
+ Tuple of (alias, path) or None if value is None
681
+
682
+ Raises:
683
+ click.BadParameter: If the format is invalid
684
+ """
685
+ if not value:
686
+ return None
687
+
688
+ if "=" in value:
689
+ # Equals syntax: --file code_file=src/main.py
690
+ if value.count("=") != 1:
691
+ raise click.BadParameter(
692
+ f"Invalid format '{value}'. Use name=path or just path."
693
+ )
694
+ name, path = value.split("=", 1)
695
+ if not name.strip():
696
+ raise click.BadParameter(f"Empty variable name in '{value}'")
697
+ if not path.strip():
698
+ raise click.BadParameter(f"Empty path in '{value}'")
699
+ name = name.strip()
700
+ path = path.strip()
701
+ if not name.isidentifier():
702
+ raise click.BadParameter(f"Invalid variable name: {name}")
703
+ if not Path(path).exists():
704
+ raise click.BadParameter(f"File not found: {path}")
705
+ return (name, path)
706
+ else:
707
+ # Simple path: --file config.yaml
708
+ path = value.strip()
709
+ if not path:
710
+ raise click.BadParameter("Empty file path")
711
+ if not Path(path).exists():
712
+ raise click.BadParameter(f"File not found: {path}")
713
+ # Mark as auto-name with None, will be processed later
714
+ return (None, path)