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
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 .
|
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
|
-
|
74
|
-
|
75
|
-
- Equals syntax:
|
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 (
|
78
|
-
|
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
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
53
|
+
Args:
|
54
|
+
ctx: Click context
|
55
|
+
param: Click parameter
|
56
|
+
value: List of file specifications from multiple options
|
84
57
|
|
85
|
-
|
86
|
-
|
58
|
+
Returns:
|
59
|
+
List of (variable_name, path) tuples where variable_name=None means auto-generate
|
87
60
|
|
88
|
-
|
89
|
-
|
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:
|
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:
|
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
|
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
|
-
#
|
412
|
-
|
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("
|
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
|
471
|
-
logger.debug("Processing
|
472
|
-
|
473
|
-
|
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
|
-
|
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)
|