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/config.py
CHANGED
@@ -8,6 +8,8 @@ from typing import Any, Dict, Optional, Union
|
|
8
8
|
import yaml
|
9
9
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
10
10
|
|
11
|
+
from .constants import DefaultConfig, DefaultSecurity
|
12
|
+
|
11
13
|
logger = logging.getLogger(__name__)
|
12
14
|
|
13
15
|
|
@@ -40,14 +42,10 @@ class ToolsConfig(BaseModel):
|
|
40
42
|
"""Configuration for tool-specific settings."""
|
41
43
|
|
42
44
|
code_interpreter: Dict[str, Any] = Field(
|
43
|
-
default_factory=lambda:
|
44
|
-
"auto_download": True,
|
45
|
-
"output_directory": "./output",
|
46
|
-
"download_strategy": "single_pass", # "single_pass" | "two_pass_sentinel"
|
47
|
-
}
|
45
|
+
default_factory=lambda: DefaultConfig.CODE_INTERPRETER.copy()
|
48
46
|
)
|
49
47
|
file_search: Dict[str, Any] = Field(
|
50
|
-
default_factory=lambda:
|
48
|
+
default_factory=lambda: DefaultConfig.FILE_SEARCH.copy()
|
51
49
|
)
|
52
50
|
web_search: WebSearchToolConfig = Field(
|
53
51
|
default_factory=WebSearchToolConfig
|
@@ -57,30 +55,33 @@ class ToolsConfig(BaseModel):
|
|
57
55
|
class ModelsConfig(BaseModel):
|
58
56
|
"""Configuration for model settings."""
|
59
57
|
|
60
|
-
default: str =
|
58
|
+
default: str = DefaultConfig.DEFAULT_MODEL
|
61
59
|
|
62
60
|
|
63
61
|
class OperationConfig(BaseModel):
|
64
62
|
"""Configuration for operation settings."""
|
65
63
|
|
66
|
-
timeout_minutes: int =
|
67
|
-
retry_attempts: int =
|
68
|
-
require_approval: str =
|
64
|
+
timeout_minutes: int = DefaultConfig.OPERATION_TIMEOUT_MINUTES
|
65
|
+
retry_attempts: int = DefaultConfig.OPERATION_RETRY_ATTEMPTS
|
66
|
+
require_approval: str = DefaultConfig.OPERATION_REQUIRE_APPROVAL
|
69
67
|
|
70
68
|
@field_validator("require_approval")
|
71
69
|
@classmethod
|
72
70
|
def validate_approval_setting(cls, v: str) -> str:
|
73
|
-
|
74
|
-
|
75
|
-
|
71
|
+
if v not in DefaultSecurity.VALID_APPROVAL_SETTINGS:
|
72
|
+
raise ValueError(
|
73
|
+
f"require_approval must be one of {DefaultSecurity.VALID_APPROVAL_SETTINGS}"
|
74
|
+
)
|
76
75
|
return v
|
77
76
|
|
78
77
|
|
79
78
|
class LimitsConfig(BaseModel):
|
80
79
|
"""Configuration for cost and operation limits."""
|
81
80
|
|
82
|
-
max_cost_per_run: float =
|
83
|
-
warn_expensive_operations: bool =
|
81
|
+
max_cost_per_run: float = DefaultConfig.LIMITS_MAX_COST_PER_RUN
|
82
|
+
warn_expensive_operations: bool = (
|
83
|
+
DefaultConfig.LIMITS_WARN_EXPENSIVE_OPERATIONS
|
84
|
+
)
|
84
85
|
|
85
86
|
|
86
87
|
class OstructConfig(BaseModel):
|
@@ -173,12 +174,12 @@ class OstructConfig(BaseModel):
|
|
173
174
|
# MCP server URLs from environment
|
174
175
|
mcp_config = config_data.setdefault("mcp", {})
|
175
176
|
|
176
|
-
# Look for
|
177
|
+
# Look for OSTRUCT_MCP_URL_* environment variables
|
177
178
|
for key, value in os.environ.items():
|
178
|
-
if key.startswith("
|
179
|
+
if key.startswith("OSTRUCT_MCP_URL_"):
|
179
180
|
server_name = key[
|
180
|
-
|
181
|
-
].lower() # Remove
|
181
|
+
16:
|
182
|
+
].lower() # Remove OSTRUCT_MCP_URL_ prefix
|
182
183
|
mcp_config[server_name] = value
|
183
184
|
|
184
185
|
# Built-in MCP server shortcuts
|
@@ -189,7 +190,7 @@ class OstructConfig(BaseModel):
|
|
189
190
|
|
190
191
|
for name, url in builtin_servers.items():
|
191
192
|
if name not in mcp_config:
|
192
|
-
env_key = f"
|
193
|
+
env_key = f"OSTRUCT_MCP_URL_{name}"
|
193
194
|
if os.getenv(env_key):
|
194
195
|
mcp_config[name] = os.getenv(env_key)
|
195
196
|
|
@@ -251,7 +252,7 @@ models:
|
|
251
252
|
tools:
|
252
253
|
code_interpreter:
|
253
254
|
auto_download: true
|
254
|
-
output_directory: "./
|
255
|
+
output_directory: "./downloads"
|
255
256
|
|
256
257
|
file_search:
|
257
258
|
max_results: 10
|
@@ -287,7 +288,7 @@ limits:
|
|
287
288
|
|
288
289
|
# Environment Variables for Secrets:
|
289
290
|
# OPENAI_API_KEY - Your OpenAI API key
|
290
|
-
#
|
291
|
+
# OSTRUCT_MCP_URL_<name> - URL for custom MCP servers (e.g., OSTRUCT_MCP_URL_stripe)
|
291
292
|
"""
|
292
293
|
|
293
294
|
|
ostruct/cli/constants.py
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
"""Centralized constants for ostruct CLI."""
|
2
|
+
|
3
|
+
from typing import Any, Dict
|
4
|
+
|
5
|
+
|
6
|
+
class DefaultPaths:
|
7
|
+
"""Default paths and directories for ostruct."""
|
8
|
+
|
9
|
+
# Code Interpreter defaults
|
10
|
+
CODE_INTERPRETER_OUTPUT_DIR = "./downloads"
|
11
|
+
|
12
|
+
# Configuration file paths
|
13
|
+
CONFIG_FILE_NAME = "ostruct.yaml"
|
14
|
+
HOME_CONFIG_DIR = ".ostruct"
|
15
|
+
HOME_CONFIG_FILE = "config.yaml"
|
16
|
+
|
17
|
+
|
18
|
+
class DefaultConfig:
|
19
|
+
"""Default configuration values for ostruct."""
|
20
|
+
|
21
|
+
# Model defaults
|
22
|
+
DEFAULT_MODEL: str = "gpt-4.1"
|
23
|
+
|
24
|
+
# Code Interpreter defaults
|
25
|
+
CODE_INTERPRETER: Dict[str, Any] = {
|
26
|
+
"auto_download": True,
|
27
|
+
"output_directory": DefaultPaths.CODE_INTERPRETER_OUTPUT_DIR,
|
28
|
+
"download_strategy": "single_pass",
|
29
|
+
}
|
30
|
+
|
31
|
+
# File Search defaults
|
32
|
+
FILE_SEARCH: Dict[str, Any] = {
|
33
|
+
"max_results": 10,
|
34
|
+
}
|
35
|
+
|
36
|
+
# Web Search defaults
|
37
|
+
WEB_SEARCH: Dict[str, Any] = {
|
38
|
+
"enable_by_default": False,
|
39
|
+
"search_context_size": None,
|
40
|
+
"user_location": None,
|
41
|
+
}
|
42
|
+
|
43
|
+
# Operation defaults - individual values for direct access
|
44
|
+
OPERATION_TIMEOUT_MINUTES: int = 60
|
45
|
+
OPERATION_RETRY_ATTEMPTS: int = 3
|
46
|
+
OPERATION_REQUIRE_APPROVAL: str = "never"
|
47
|
+
|
48
|
+
# Limits defaults - individual values for direct access
|
49
|
+
LIMITS_MAX_COST_PER_RUN: float = 10.00
|
50
|
+
LIMITS_WARN_EXPENSIVE_OPERATIONS: bool = True
|
51
|
+
|
52
|
+
# Template processing defaults
|
53
|
+
TEMPLATE: Dict[str, Any] = {
|
54
|
+
"system_prompt": "You are a helpful assistant.",
|
55
|
+
"file_limit": 65536, # 64KB
|
56
|
+
"total_limit": 1048576, # 1MB
|
57
|
+
"preview_limit": 4096, # 4KB
|
58
|
+
}
|
59
|
+
|
60
|
+
|
61
|
+
class DefaultTimeouts:
|
62
|
+
"""Default timeout values."""
|
63
|
+
|
64
|
+
API_TIMEOUT = 60.0
|
65
|
+
MAX_API_TIMEOUT = 300.0 # 5 minutes cap
|
66
|
+
|
67
|
+
|
68
|
+
class DefaultSecurity:
|
69
|
+
"""Default security settings."""
|
70
|
+
|
71
|
+
PATH_SECURITY_MODE = "permissive"
|
72
|
+
VALID_SECURITY_MODES = ["permissive", "warn", "strict"]
|
73
|
+
VALID_APPROVAL_SETTINGS = ["never", "always", "expensive"]
|
74
|
+
|
75
|
+
|
76
|
+
# Environment variable names (for consistency)
|
77
|
+
class EnvVars:
|
78
|
+
"""Environment variable names."""
|
79
|
+
|
80
|
+
OPENAI_API_KEY = "OPENAI_API_KEY"
|
81
|
+
OSTRUCT_DISABLE_REGISTRY_UPDATE_CHECKS = (
|
82
|
+
"OSTRUCT_DISABLE_REGISTRY_UPDATE_CHECKS"
|
83
|
+
)
|
84
|
+
OSTRUCT_TEMPLATE_FILE_LIMIT = "OSTRUCT_TEMPLATE_FILE_LIMIT"
|
85
|
+
OSTRUCT_TEMPLATE_TOTAL_LIMIT = "OSTRUCT_TEMPLATE_TOTAL_LIMIT"
|
86
|
+
OSTRUCT_TEMPLATE_PREVIEW_LIMIT = "OSTRUCT_TEMPLATE_PREVIEW_LIMIT"
|
87
|
+
|
88
|
+
# MCP URL pattern: OSTRUCT_MCP_URL_<name>
|
89
|
+
MCP_URL_PREFIX = "OSTRUCT_MCP_URL_"
|
ostruct/cli/errors.py
CHANGED
@@ -252,7 +252,20 @@ class PathSecurityError(SecurityErrorBase):
|
|
252
252
|
class TaskTemplateError(CLIError):
|
253
253
|
"""Base class for task template-related errors."""
|
254
254
|
|
255
|
-
|
255
|
+
def __init__(
|
256
|
+
self,
|
257
|
+
message: str,
|
258
|
+
context: Optional[Dict[str, Any]] = None,
|
259
|
+
exit_code: int = ExitCode.VALIDATION_ERROR,
|
260
|
+
) -> None:
|
261
|
+
"""Initialize task template error.
|
262
|
+
|
263
|
+
Args:
|
264
|
+
message: Error message
|
265
|
+
context: Additional error context
|
266
|
+
exit_code: Exit code (defaults to VALIDATION_ERROR)
|
267
|
+
"""
|
268
|
+
super().__init__(message, context=context, exit_code=exit_code)
|
256
269
|
|
257
270
|
|
258
271
|
class TaskTemplateSyntaxError(TaskTemplateError):
|
@@ -285,7 +298,22 @@ class TaskTemplateVariableError(TaskTemplateError):
|
|
285
298
|
class TemplateValidationError(TaskTemplateError):
|
286
299
|
"""Raised when template validation fails."""
|
287
300
|
|
288
|
-
|
301
|
+
def __init__(
|
302
|
+
self,
|
303
|
+
message: str,
|
304
|
+
context: Optional[Dict[str, Any]] = None,
|
305
|
+
) -> None:
|
306
|
+
"""Initialize template validation error.
|
307
|
+
|
308
|
+
Args:
|
309
|
+
message: Error message
|
310
|
+
context: Additional error context
|
311
|
+
"""
|
312
|
+
super().__init__(
|
313
|
+
message,
|
314
|
+
context=context,
|
315
|
+
exit_code=ExitCode.VALIDATION_ERROR,
|
316
|
+
)
|
289
317
|
|
290
318
|
|
291
319
|
class SystemPromptError(TaskTemplateError):
|
@@ -576,7 +604,7 @@ class APIErrorMapper:
|
|
576
604
|
):
|
577
605
|
return PromptTooLargeError(
|
578
606
|
f"Prompt exceeds model context window (128,000 token limit). "
|
579
|
-
f"Tip: Use explicit file routing (
|
607
|
+
f"Tip: Use explicit file routing (--file ci:data for code, --file fs:docs for docs, --file config for config). "
|
580
608
|
f"Original error: {error}"
|
581
609
|
)
|
582
610
|
|
@@ -645,7 +673,7 @@ class APIErrorMapper:
|
|
645
673
|
if "upload" in error_msg or "vector_store" in error_msg:
|
646
674
|
return FileSearchUploadError(
|
647
675
|
f"File Search upload failed: {error}. "
|
648
|
-
f"This can be intermittent - retry with --
|
676
|
+
f"This can be intermittent - retry with --fs-retries option."
|
649
677
|
)
|
650
678
|
return FileSearchError(f"File Search error: {error}")
|
651
679
|
|
@@ -748,7 +776,17 @@ def handle_error(e: Exception) -> None:
|
|
748
776
|
msg = f"Model creation error: {str(e)}"
|
749
777
|
exit_code = ExitCode.SCHEMA_ERROR
|
750
778
|
elif isinstance(e, click.UsageError):
|
751
|
-
|
779
|
+
error_msg = str(e)
|
780
|
+
|
781
|
+
# Enhance usage error messages with helpful guidance
|
782
|
+
if "Missing parameter" in error_msg:
|
783
|
+
if "task_template" in error_msg:
|
784
|
+
msg = f"Usage error: {error_msg}\n\nTry 'ostruct run --help' for usage information, 'ostruct --quick-ref' for examples, or 'ostruct run --help-debug' for troubleshooting help."
|
785
|
+
else:
|
786
|
+
msg = f"Usage error: {error_msg}\n\nTry 'ostruct run --help' for usage information or 'ostruct --quick-ref' for examples."
|
787
|
+
else:
|
788
|
+
msg = f"Usage error: {error_msg}"
|
789
|
+
|
752
790
|
exit_code = ExitCode.USAGE_ERROR
|
753
791
|
elif isinstance(e, SchemaFileError):
|
754
792
|
msg = str(e) # Use existing __str__ formatting
|
@@ -815,3 +853,135 @@ __all__ = [
|
|
815
853
|
"InvalidResponseFormatError",
|
816
854
|
"handle_error",
|
817
855
|
]
|
856
|
+
|
857
|
+
# Download-specific error classes for Task 3
|
858
|
+
|
859
|
+
|
860
|
+
class DownloadError(CLIError):
|
861
|
+
"""Base class for download-related errors."""
|
862
|
+
|
863
|
+
def __init__(
|
864
|
+
self,
|
865
|
+
message: str,
|
866
|
+
context: Optional[Dict[str, Any]] = None,
|
867
|
+
exit_code: int = ExitCode.API_ERROR,
|
868
|
+
) -> None:
|
869
|
+
"""Initialize download error.
|
870
|
+
|
871
|
+
Args:
|
872
|
+
message: Error message
|
873
|
+
context: Additional error context
|
874
|
+
exit_code: Exit code for the error
|
875
|
+
"""
|
876
|
+
super().__init__(message, context=context, exit_code=exit_code)
|
877
|
+
|
878
|
+
|
879
|
+
class DownloadPermissionError(DownloadError):
|
880
|
+
"""Raised when download fails due to permission issues."""
|
881
|
+
|
882
|
+
def __init__(
|
883
|
+
self,
|
884
|
+
directory: str,
|
885
|
+
context: Optional[Dict[str, Any]] = None,
|
886
|
+
) -> None:
|
887
|
+
"""Initialize permission error.
|
888
|
+
|
889
|
+
Args:
|
890
|
+
directory: Directory that caused the permission error
|
891
|
+
context: Additional error context
|
892
|
+
"""
|
893
|
+
context = context or {}
|
894
|
+
context.update(
|
895
|
+
{
|
896
|
+
"directory": directory,
|
897
|
+
"details": "Unable to write to the specified download directory",
|
898
|
+
"troubleshooting": [
|
899
|
+
f"Check write permissions for directory: {directory}",
|
900
|
+
"Verify the directory exists and is accessible",
|
901
|
+
"Try using a different download directory with --ci-download-dir",
|
902
|
+
"Check if the parent directory exists and is writable",
|
903
|
+
"Ensure sufficient disk space is available",
|
904
|
+
],
|
905
|
+
}
|
906
|
+
)
|
907
|
+
|
908
|
+
message = f"Permission denied when writing to download directory: {directory}"
|
909
|
+
super().__init__(
|
910
|
+
message, context=context, exit_code=ExitCode.FILE_ERROR
|
911
|
+
)
|
912
|
+
|
913
|
+
|
914
|
+
class DownloadNetworkError(DownloadError):
|
915
|
+
"""Raised when download fails due to network issues."""
|
916
|
+
|
917
|
+
def __init__(
|
918
|
+
self,
|
919
|
+
file_id: str,
|
920
|
+
original_error: Optional[Exception] = None,
|
921
|
+
context: Optional[Dict[str, Any]] = None,
|
922
|
+
) -> None:
|
923
|
+
"""Initialize network error.
|
924
|
+
|
925
|
+
Args:
|
926
|
+
file_id: File ID that failed to download
|
927
|
+
original_error: Original exception that caused the failure
|
928
|
+
context: Additional error context
|
929
|
+
"""
|
930
|
+
context = context or {}
|
931
|
+
context.update(
|
932
|
+
{
|
933
|
+
"file_id": file_id,
|
934
|
+
"details": "Network error occurred while downloading file from OpenAI",
|
935
|
+
"troubleshooting": [
|
936
|
+
"Check your internet connection",
|
937
|
+
"Verify OpenAI API is accessible",
|
938
|
+
"Try the download again in a few moments",
|
939
|
+
"Check if your API key has the necessary permissions",
|
940
|
+
"Ensure the file ID is valid and not expired",
|
941
|
+
],
|
942
|
+
}
|
943
|
+
)
|
944
|
+
|
945
|
+
if original_error:
|
946
|
+
context["original_error"] = str(original_error)
|
947
|
+
|
948
|
+
message = f"Network error downloading file {file_id}"
|
949
|
+
if original_error:
|
950
|
+
message += f": {original_error}"
|
951
|
+
|
952
|
+
super().__init__(message, context=context)
|
953
|
+
|
954
|
+
|
955
|
+
class DownloadFileNotFoundError(DownloadError):
|
956
|
+
"""Raised when a file to download is not found."""
|
957
|
+
|
958
|
+
def __init__(
|
959
|
+
self,
|
960
|
+
file_id: str,
|
961
|
+
context: Optional[Dict[str, Any]] = None,
|
962
|
+
) -> None:
|
963
|
+
"""Initialize file not found error.
|
964
|
+
|
965
|
+
Args:
|
966
|
+
file_id: File ID that was not found
|
967
|
+
context: Additional error context
|
968
|
+
"""
|
969
|
+
context = context or {}
|
970
|
+
context.update(
|
971
|
+
{
|
972
|
+
"file_id": file_id,
|
973
|
+
"details": "The requested file was not found or is no longer available",
|
974
|
+
"troubleshooting": [
|
975
|
+
"Verify the file ID is correct",
|
976
|
+
"Check if the file was generated in this session",
|
977
|
+
"Ensure the Code Interpreter execution completed successfully",
|
978
|
+
"Try running the analysis again to regenerate the file",
|
979
|
+
"Check if the file has expired (files have limited lifetime)",
|
980
|
+
],
|
981
|
+
}
|
982
|
+
)
|
983
|
+
|
984
|
+
message = f"File not found for download: {file_id}"
|
985
|
+
super().__init__(
|
986
|
+
message, context=context, exit_code=ExitCode.FILE_ERROR
|
987
|
+
)
|
@@ -135,21 +135,6 @@ class ExplicitFileProcessor:
|
|
135
135
|
"""
|
136
136
|
routing = ExplicitRouting()
|
137
137
|
|
138
|
-
# Legacy options (-f, -d) are handled separately in create_template_context_from_routing
|
139
|
-
# to preserve their custom variable naming semantics
|
140
|
-
legacy_files = args.get("files", [])
|
141
|
-
legacy_dirs = args.get("dir", [])
|
142
|
-
|
143
|
-
if legacy_files:
|
144
|
-
logger.debug(
|
145
|
-
f"Legacy -f flag detected: {len(legacy_files)} files (handled separately)"
|
146
|
-
)
|
147
|
-
|
148
|
-
if legacy_dirs:
|
149
|
-
logger.debug(
|
150
|
-
f"Legacy -d flag detected: {len(legacy_dirs)} dirs (handled separately)"
|
151
|
-
)
|
152
|
-
|
153
138
|
# Handle explicit tool routing - file options now have different formats
|
154
139
|
|
155
140
|
# Template files (from -ft) - now single-argument auto-naming
|
ostruct/cli/file_info.py
CHANGED
@@ -5,7 +5,7 @@ import logging
|
|
5
5
|
import os
|
6
6
|
from enum import Enum
|
7
7
|
from pathlib import Path
|
8
|
-
from typing import Any, Optional
|
8
|
+
from typing import Any, Iterator, Optional
|
9
9
|
|
10
10
|
from .errors import FileReadError, OstructFileNotFoundError, PathSecurityError
|
11
11
|
from .security import SecurityManager
|
@@ -18,9 +18,9 @@ class FileRoutingIntent(Enum):
|
|
18
18
|
to provide appropriate warnings and optimizations.
|
19
19
|
"""
|
20
20
|
|
21
|
-
TEMPLATE_ONLY = "template_only" #
|
22
|
-
CODE_INTERPRETER = "code_interpreter" #
|
23
|
-
FILE_SEARCH = "file_search" #
|
21
|
+
TEMPLATE_ONLY = "template_only" # --file [alias] (template access only)
|
22
|
+
CODE_INTERPRETER = "code_interpreter" # --file ci:[alias]
|
23
|
+
FILE_SEARCH = "file_search" # --file fs:[alias]
|
24
24
|
|
25
25
|
|
26
26
|
logger = logging.getLogger(__name__)
|
@@ -30,7 +30,8 @@ class FileInfo:
|
|
30
30
|
"""Represents a file with metadata and content.
|
31
31
|
|
32
32
|
This class provides access to file metadata (path, size, etc.) and content,
|
33
|
-
with caching support for efficient access.
|
33
|
+
with caching support for efficient access. Implements the file-sequence protocol
|
34
|
+
by being iterable (yields itself) while maintaining scalar access to properties.
|
34
35
|
|
35
36
|
Args:
|
36
37
|
path: Path to the file
|
@@ -77,6 +78,19 @@ class FileInfo:
|
|
77
78
|
self.routing_type = routing_type
|
78
79
|
self.routing_intent = routing_intent
|
79
80
|
|
81
|
+
# TSES v2.0 fields for alias tracking
|
82
|
+
self.parent_alias: Optional[str] = (
|
83
|
+
None # CLI alias this file came from
|
84
|
+
)
|
85
|
+
self.relative_path: Optional[str] = (
|
86
|
+
None # Path relative to attachment root
|
87
|
+
)
|
88
|
+
self.base_path: Optional[str] = None # Base path of attachment
|
89
|
+
self.from_collection: bool = False # Whether file came from --collect
|
90
|
+
self.attachment_type: str = (
|
91
|
+
"file" # Original attachment type: "file", "dir", or "collection"
|
92
|
+
)
|
93
|
+
|
80
94
|
logger.debug(
|
81
95
|
"Creating FileInfo for path: %s, routing_type: %s",
|
82
96
|
path,
|
@@ -149,6 +163,39 @@ class FileInfo:
|
|
149
163
|
f"Permission denied: {os.path.basename(str(path))}"
|
150
164
|
) from e
|
151
165
|
|
166
|
+
def __iter__(self) -> Iterator["FileInfo"]:
|
167
|
+
"""Make FileInfo iterable by yielding itself.
|
168
|
+
|
169
|
+
This implements the file-sequence protocol, allowing single files
|
170
|
+
to be treated uniformly with file collections in templates.
|
171
|
+
|
172
|
+
Returns:
|
173
|
+
Iterator that yields this FileInfo instance
|
174
|
+
"""
|
175
|
+
yield self
|
176
|
+
|
177
|
+
@property
|
178
|
+
def first(self) -> "FileInfo":
|
179
|
+
"""Get the first file in the sequence (itself for single files).
|
180
|
+
|
181
|
+
This provides a uniform interface with FileInfoList.first,
|
182
|
+
allowing templates to use .first regardless of whether they're
|
183
|
+
dealing with a single file or a collection.
|
184
|
+
|
185
|
+
Returns:
|
186
|
+
This FileInfo instance
|
187
|
+
"""
|
188
|
+
return self
|
189
|
+
|
190
|
+
@property
|
191
|
+
def is_collection(self) -> bool:
|
192
|
+
"""Indicate whether this is a collection of files.
|
193
|
+
|
194
|
+
Returns:
|
195
|
+
False, since FileInfo represents a single file
|
196
|
+
"""
|
197
|
+
return False
|
198
|
+
|
152
199
|
@property
|
153
200
|
def path(self) -> str:
|
154
201
|
"""Get the path relative to security manager's base directory.
|
@@ -181,13 +228,18 @@ class FileInfo:
|
|
181
228
|
try:
|
182
229
|
return str(abs_path.relative_to(base_dir))
|
183
230
|
except ValueError:
|
184
|
-
# Path is outside base_dir, check if it's
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
231
|
+
# Path is outside base_dir, check if it's allowed by enhanced security model
|
232
|
+
try:
|
233
|
+
if self.__security_manager.is_path_allowed_enhanced(abs_path):
|
234
|
+
logger.debug(
|
235
|
+
"Path outside base_dir but allowed, returning absolute path: %s",
|
236
|
+
abs_path,
|
237
|
+
)
|
238
|
+
return str(abs_path)
|
239
|
+
except Exception:
|
240
|
+
# In strict mode, is_path_allowed_enhanced() raises exceptions
|
241
|
+
# If we reach this except block, the path is not allowed
|
242
|
+
pass
|
191
243
|
|
192
244
|
# Should never reach here if SecurityManager validation was done properly
|
193
245
|
logger.error(
|
@@ -196,7 +248,7 @@ class FileInfo:
|
|
196
248
|
base_dir,
|
197
249
|
)
|
198
250
|
raise ValueError(
|
199
|
-
f"Path {abs_path}
|
251
|
+
f"Path {abs_path} is not within base directory {base_dir}"
|
200
252
|
)
|
201
253
|
|
202
254
|
@path.setter
|
@@ -302,7 +354,7 @@ class FileInfo:
|
|
302
354
|
f"File '{self.path}' ({self.size / 1024:.1f}KB) was routed for template-only access "
|
303
355
|
f"but its .content is being accessed. This will include the entire file content "
|
304
356
|
f"in the prompt sent to the AI. For large files intended for analysis or search, "
|
305
|
-
f"consider using
|
357
|
+
f"consider using --file ci:data (Code Interpreter) or --file fs:docs (File Search) to optimize token usage, "
|
306
358
|
f"cost, and avoid exceeding model context limits."
|
307
359
|
)
|
308
360
|
|
@@ -433,6 +485,11 @@ class FileInfo:
|
|
433
485
|
security_manager: SecurityManager,
|
434
486
|
routing_type: Optional[str] = None,
|
435
487
|
routing_intent: Optional[FileRoutingIntent] = None,
|
488
|
+
parent_alias: Optional[str] = None,
|
489
|
+
relative_path: Optional[str] = None,
|
490
|
+
base_path: Optional[str] = None,
|
491
|
+
from_collection: bool = False,
|
492
|
+
attachment_type: str = "file",
|
436
493
|
) -> "FileInfo":
|
437
494
|
"""Create FileInfo instance from path.
|
438
495
|
|
@@ -441,6 +498,11 @@ class FileInfo:
|
|
441
498
|
security_manager: Security manager for path validation
|
442
499
|
routing_type: How the file was routed (e.g., 'template', 'code-interpreter')
|
443
500
|
routing_intent: The intended use of the file in the pipeline
|
501
|
+
parent_alias: CLI alias this file came from (for TSES)
|
502
|
+
relative_path: Path relative to attachment root (for TSES)
|
503
|
+
base_path: Base path of attachment (for TSES)
|
504
|
+
from_collection: Whether file came from --collect (for TSES)
|
505
|
+
attachment_type: Original attachment type: "file", "dir", or "collection" (for TSES)
|
444
506
|
|
445
507
|
Returns:
|
446
508
|
FileInfo instance
|
@@ -449,13 +511,22 @@ class FileInfo:
|
|
449
511
|
FileNotFoundError: If file does not exist
|
450
512
|
PathSecurityError: If path is not allowed
|
451
513
|
"""
|
452
|
-
|
514
|
+
file_info = cls(
|
453
515
|
path,
|
454
516
|
security_manager,
|
455
517
|
routing_type=routing_type,
|
456
518
|
routing_intent=routing_intent,
|
457
519
|
)
|
458
520
|
|
521
|
+
# Set TSES fields
|
522
|
+
file_info.parent_alias = parent_alias
|
523
|
+
file_info.relative_path = relative_path
|
524
|
+
file_info.base_path = base_path
|
525
|
+
file_info.from_collection = from_collection
|
526
|
+
file_info.attachment_type = attachment_type
|
527
|
+
|
528
|
+
return file_info
|
529
|
+
|
459
530
|
def __str__(self) -> str:
|
460
531
|
"""String representation showing path."""
|
461
532
|
return f"FileInfo({self.__path})"
|
@@ -481,6 +552,17 @@ class FileInfo:
|
|
481
552
|
object.__setattr__(self, name, value)
|
482
553
|
return
|
483
554
|
|
555
|
+
# Allow setting TSES fields
|
556
|
+
if name in (
|
557
|
+
"parent_alias",
|
558
|
+
"relative_path",
|
559
|
+
"base_path",
|
560
|
+
"from_collection",
|
561
|
+
"attachment_type",
|
562
|
+
):
|
563
|
+
object.__setattr__(self, name, value)
|
564
|
+
return
|
565
|
+
|
484
566
|
# Allow setting private attributes from internal methods
|
485
567
|
if name.startswith("_FileInfo__") and self._is_internal_call():
|
486
568
|
object.__setattr__(self, name, value)
|