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.
- 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 +187 -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 +191 -6
- ostruct/cli/explicit_file_processor.py +0 -15
- ostruct/cli/file_info.py +118 -14
- ostruct/cli/file_list.py +82 -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/utils.py +30 -0
- ostruct/cli/validators.py +272 -54
- {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/METADATA +292 -126
- 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.8.dist-info/RECORD +0 -71
- {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.8.8.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,13 +298,43 @@ 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):
|
292
320
|
"""Raised when there are issues with system prompt loading or processing."""
|
293
321
|
|
294
|
-
|
322
|
+
def __init__(
|
323
|
+
self,
|
324
|
+
message: str,
|
325
|
+
context: Optional[Dict[str, Any]] = None,
|
326
|
+
) -> None:
|
327
|
+
"""Initialize system prompt error.
|
328
|
+
|
329
|
+
Args:
|
330
|
+
message: Error message
|
331
|
+
context: Additional error context
|
332
|
+
"""
|
333
|
+
super().__init__(
|
334
|
+
message,
|
335
|
+
context=context,
|
336
|
+
exit_code=ExitCode.VALIDATION_ERROR,
|
337
|
+
)
|
295
338
|
|
296
339
|
|
297
340
|
class SchemaError(CLIError):
|
@@ -561,7 +604,7 @@ class APIErrorMapper:
|
|
561
604
|
):
|
562
605
|
return PromptTooLargeError(
|
563
606
|
f"Prompt exceeds model context window (128,000 token limit). "
|
564
|
-
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). "
|
565
608
|
f"Original error: {error}"
|
566
609
|
)
|
567
610
|
|
@@ -630,7 +673,7 @@ class APIErrorMapper:
|
|
630
673
|
if "upload" in error_msg or "vector_store" in error_msg:
|
631
674
|
return FileSearchUploadError(
|
632
675
|
f"File Search upload failed: {error}. "
|
633
|
-
f"This can be intermittent - retry with --
|
676
|
+
f"This can be intermittent - retry with --fs-retries option."
|
634
677
|
)
|
635
678
|
return FileSearchError(f"File Search error: {error}")
|
636
679
|
|
@@ -733,7 +776,17 @@ def handle_error(e: Exception) -> None:
|
|
733
776
|
msg = f"Model creation error: {str(e)}"
|
734
777
|
exit_code = ExitCode.SCHEMA_ERROR
|
735
778
|
elif isinstance(e, click.UsageError):
|
736
|
-
|
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
|
+
|
737
790
|
exit_code = ExitCode.USAGE_ERROR
|
738
791
|
elif isinstance(e, SchemaFileError):
|
739
792
|
msg = str(e) # Use existing __str__ formatting
|
@@ -800,3 +853,135 @@ __all__ = [
|
|
800
853
|
"InvalidResponseFormatError",
|
801
854
|
"handle_error",
|
802
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.
|
@@ -156,25 +203,52 @@ class FileInfo:
|
|
156
203
|
Returns a path relative to the security manager's base directory.
|
157
204
|
This ensures consistent path handling across the entire codebase.
|
158
205
|
|
206
|
+
For paths outside the base directory but within allowed directories,
|
207
|
+
returns the absolute path.
|
208
|
+
|
159
209
|
Example:
|
160
210
|
security_manager = SecurityManager(base_dir="/base")
|
161
211
|
file_info = FileInfo("/base/file.txt", security_manager)
|
162
212
|
print(file_info.path) # Outputs: "file.txt"
|
163
213
|
|
214
|
+
# With allowed directory outside base:
|
215
|
+
file_info = FileInfo("/tmp/file.txt", security_manager)
|
216
|
+
print(file_info.path) # Outputs: "/tmp/file.txt"
|
217
|
+
|
164
218
|
Returns:
|
165
|
-
str: Path relative to security manager's base directory
|
219
|
+
str: Path relative to security manager's base directory, or absolute path
|
220
|
+
if outside base directory but within allowed directories
|
166
221
|
|
167
222
|
Raises:
|
168
|
-
ValueError: If the path is not within the base directory
|
223
|
+
ValueError: If the path is not within the base directory or allowed directories
|
169
224
|
"""
|
225
|
+
abs_path = Path(self.abs_path)
|
226
|
+
base_dir = Path(self.__security_manager.base_dir)
|
227
|
+
|
170
228
|
try:
|
171
|
-
abs_path = Path(self.abs_path)
|
172
|
-
base_dir = Path(self.__security_manager.base_dir)
|
173
229
|
return str(abs_path.relative_to(base_dir))
|
174
|
-
except ValueError
|
175
|
-
|
230
|
+
except ValueError:
|
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
|
243
|
+
|
244
|
+
# Should never reach here if SecurityManager validation was done properly
|
245
|
+
logger.error(
|
246
|
+
"Error making path relative: %s is not within base directory %s",
|
247
|
+
abs_path,
|
248
|
+
base_dir,
|
249
|
+
)
|
176
250
|
raise ValueError(
|
177
|
-
f"Path {abs_path}
|
251
|
+
f"Path {abs_path} is not within base directory {base_dir}"
|
178
252
|
)
|
179
253
|
|
180
254
|
@path.setter
|
@@ -280,7 +354,7 @@ class FileInfo:
|
|
280
354
|
f"File '{self.path}' ({self.size / 1024:.1f}KB) was routed for template-only access "
|
281
355
|
f"but its .content is being accessed. This will include the entire file content "
|
282
356
|
f"in the prompt sent to the AI. For large files intended for analysis or search, "
|
283
|
-
f"consider using
|
357
|
+
f"consider using --file ci:data (Code Interpreter) or --file fs:docs (File Search) to optimize token usage, "
|
284
358
|
f"cost, and avoid exceeding model context limits."
|
285
359
|
)
|
286
360
|
|
@@ -411,6 +485,11 @@ class FileInfo:
|
|
411
485
|
security_manager: SecurityManager,
|
412
486
|
routing_type: Optional[str] = None,
|
413
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",
|
414
493
|
) -> "FileInfo":
|
415
494
|
"""Create FileInfo instance from path.
|
416
495
|
|
@@ -419,6 +498,11 @@ class FileInfo:
|
|
419
498
|
security_manager: Security manager for path validation
|
420
499
|
routing_type: How the file was routed (e.g., 'template', 'code-interpreter')
|
421
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)
|
422
506
|
|
423
507
|
Returns:
|
424
508
|
FileInfo instance
|
@@ -427,13 +511,22 @@ class FileInfo:
|
|
427
511
|
FileNotFoundError: If file does not exist
|
428
512
|
PathSecurityError: If path is not allowed
|
429
513
|
"""
|
430
|
-
|
514
|
+
file_info = cls(
|
431
515
|
path,
|
432
516
|
security_manager,
|
433
517
|
routing_type=routing_type,
|
434
518
|
routing_intent=routing_intent,
|
435
519
|
)
|
436
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
|
+
|
437
530
|
def __str__(self) -> str:
|
438
531
|
"""String representation showing path."""
|
439
532
|
return f"FileInfo({self.__path})"
|
@@ -459,6 +552,17 @@ class FileInfo:
|
|
459
552
|
object.__setattr__(self, name, value)
|
460
553
|
return
|
461
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
|
+
|
462
566
|
# Allow setting private attributes from internal methods
|
463
567
|
if name.startswith("_FileInfo__") and self._is_internal_call():
|
464
568
|
object.__setattr__(self, name, value)
|