ostruct-cli 0.1.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/__init__.py +0 -0
- ostruct/cli/__init__.py +19 -0
- ostruct/cli/cache_manager.py +175 -0
- ostruct/cli/cli.py +2033 -0
- ostruct/cli/errors.py +329 -0
- ostruct/cli/file_info.py +316 -0
- ostruct/cli/file_list.py +151 -0
- ostruct/cli/file_utils.py +518 -0
- ostruct/cli/path_utils.py +123 -0
- ostruct/cli/progress.py +105 -0
- ostruct/cli/security.py +311 -0
- ostruct/cli/security_types.py +49 -0
- ostruct/cli/template_env.py +55 -0
- ostruct/cli/template_extensions.py +51 -0
- ostruct/cli/template_filters.py +650 -0
- ostruct/cli/template_io.py +261 -0
- ostruct/cli/template_rendering.py +347 -0
- ostruct/cli/template_schema.py +565 -0
- ostruct/cli/template_utils.py +288 -0
- ostruct/cli/template_validation.py +375 -0
- ostruct/cli/utils.py +31 -0
- ostruct/py.typed +0 -0
- ostruct_cli-0.1.0.dist-info/LICENSE +21 -0
- ostruct_cli-0.1.0.dist-info/METADATA +182 -0
- ostruct_cli-0.1.0.dist-info/RECORD +27 -0
- ostruct_cli-0.1.0.dist-info/WHEEL +4 -0
- ostruct_cli-0.1.0.dist-info/entry_points.txt +3 -0
ostruct/cli/errors.py
ADDED
@@ -0,0 +1,329 @@
|
|
1
|
+
"""Custom error classes for CLI error handling."""
|
2
|
+
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import List, Optional
|
5
|
+
|
6
|
+
|
7
|
+
class CLIError(Exception):
|
8
|
+
"""Base class for all CLI errors."""
|
9
|
+
|
10
|
+
pass
|
11
|
+
|
12
|
+
|
13
|
+
class VariableError(CLIError):
|
14
|
+
"""Base class for variable-related errors."""
|
15
|
+
|
16
|
+
pass
|
17
|
+
|
18
|
+
|
19
|
+
class VariableNameError(VariableError):
|
20
|
+
"""Raised when a variable name is invalid or empty."""
|
21
|
+
|
22
|
+
pass
|
23
|
+
|
24
|
+
|
25
|
+
class VariableValueError(VariableError):
|
26
|
+
"""Raised when a variable value is invalid or missing."""
|
27
|
+
|
28
|
+
pass
|
29
|
+
|
30
|
+
|
31
|
+
class InvalidJSONError(VariableError):
|
32
|
+
"""Raised when JSON parsing fails for a variable value."""
|
33
|
+
|
34
|
+
pass
|
35
|
+
|
36
|
+
|
37
|
+
class PathError(CLIError):
|
38
|
+
"""Base class for path-related errors."""
|
39
|
+
|
40
|
+
pass
|
41
|
+
|
42
|
+
|
43
|
+
class FileNotFoundError(PathError):
|
44
|
+
"""Raised when a specified file does not exist."""
|
45
|
+
|
46
|
+
pass
|
47
|
+
|
48
|
+
|
49
|
+
class DirectoryNotFoundError(PathError):
|
50
|
+
"""Raised when a specified directory does not exist."""
|
51
|
+
|
52
|
+
pass
|
53
|
+
|
54
|
+
|
55
|
+
class PathSecurityError(Exception):
|
56
|
+
"""Exception raised when file access is denied due to security constraints.
|
57
|
+
|
58
|
+
Attributes:
|
59
|
+
message: The error message with full context
|
60
|
+
error_logged: Whether this error has already been logged
|
61
|
+
wrapped: Whether this error has been wrapped by another error
|
62
|
+
"""
|
63
|
+
|
64
|
+
def __init__(
|
65
|
+
self, message: str, error_logged: bool = False, wrapped: bool = False
|
66
|
+
) -> None:
|
67
|
+
"""Initialize PathSecurityError.
|
68
|
+
|
69
|
+
Args:
|
70
|
+
message: Detailed error message with context
|
71
|
+
error_logged: Whether this error has already been logged
|
72
|
+
wrapped: Whether this error has been wrapped by another error
|
73
|
+
"""
|
74
|
+
super().__init__(message)
|
75
|
+
self.error_logged = error_logged
|
76
|
+
self.message = message
|
77
|
+
self.wrapped = wrapped
|
78
|
+
|
79
|
+
@property
|
80
|
+
def has_been_logged(self) -> bool:
|
81
|
+
"""Check if this error has been logged, more readable than accessing error_logged directly."""
|
82
|
+
return self.error_logged
|
83
|
+
|
84
|
+
def __str__(self) -> str:
|
85
|
+
"""Get string representation of the error."""
|
86
|
+
return self.message
|
87
|
+
|
88
|
+
@classmethod
|
89
|
+
def access_denied(
|
90
|
+
cls,
|
91
|
+
path: Path,
|
92
|
+
reason: Optional[str] = None,
|
93
|
+
error_logged: bool = False,
|
94
|
+
) -> "PathSecurityError":
|
95
|
+
"""Create access denied error.
|
96
|
+
|
97
|
+
Args:
|
98
|
+
path: Path that was denied
|
99
|
+
reason: Optional reason for denial
|
100
|
+
error_logged: Whether this error has already been logged
|
101
|
+
|
102
|
+
Returns:
|
103
|
+
PathSecurityError with standardized message
|
104
|
+
"""
|
105
|
+
msg = f"Access denied: {path}"
|
106
|
+
if reason:
|
107
|
+
msg += f" - {reason}"
|
108
|
+
return cls(msg, error_logged=error_logged)
|
109
|
+
|
110
|
+
@classmethod
|
111
|
+
def outside_allowed(
|
112
|
+
cls,
|
113
|
+
path: Path,
|
114
|
+
base_dir: Optional[Path] = None,
|
115
|
+
error_logged: bool = False,
|
116
|
+
) -> "PathSecurityError":
|
117
|
+
"""Create error for path outside allowed directories.
|
118
|
+
|
119
|
+
Args:
|
120
|
+
path: Path that was outside
|
121
|
+
base_dir: Optional base directory for context
|
122
|
+
error_logged: Whether this error has already been logged
|
123
|
+
|
124
|
+
Returns:
|
125
|
+
PathSecurityError with standardized message
|
126
|
+
"""
|
127
|
+
parts = [
|
128
|
+
f"Access denied: {path} is outside base directory and not in allowed directories"
|
129
|
+
]
|
130
|
+
if base_dir:
|
131
|
+
parts.append(f"Base directory: {base_dir}")
|
132
|
+
parts.append(
|
133
|
+
"Use --allowed-dir to specify additional allowed directories"
|
134
|
+
)
|
135
|
+
return cls("\n".join(parts), error_logged=error_logged)
|
136
|
+
|
137
|
+
@classmethod
|
138
|
+
def traversal_attempt(
|
139
|
+
cls, path: Path, error_logged: bool = False
|
140
|
+
) -> "PathSecurityError":
|
141
|
+
"""Create error for directory traversal attempt.
|
142
|
+
|
143
|
+
Args:
|
144
|
+
path: Path that attempted traversal
|
145
|
+
error_logged: Whether this error has already been logged
|
146
|
+
|
147
|
+
Returns:
|
148
|
+
PathSecurityError with standardized message
|
149
|
+
"""
|
150
|
+
return cls(
|
151
|
+
f"Access denied: {path} - directory traversal not allowed",
|
152
|
+
error_logged=error_logged,
|
153
|
+
)
|
154
|
+
|
155
|
+
@classmethod
|
156
|
+
def from_expanded_paths(
|
157
|
+
cls,
|
158
|
+
original_path: str,
|
159
|
+
expanded_path: str,
|
160
|
+
base_dir: Optional[str] = None,
|
161
|
+
allowed_dirs: Optional[List[str]] = None,
|
162
|
+
error_logged: bool = False,
|
163
|
+
) -> "PathSecurityError":
|
164
|
+
"""Create error with expanded path context.
|
165
|
+
|
166
|
+
Args:
|
167
|
+
original_path: Original path as provided by user
|
168
|
+
expanded_path: Expanded absolute path
|
169
|
+
base_dir: Optional base directory
|
170
|
+
allowed_dirs: Optional list of allowed directories
|
171
|
+
error_logged: Whether this error has already been logged
|
172
|
+
|
173
|
+
Returns:
|
174
|
+
PathSecurityError with detailed path context
|
175
|
+
"""
|
176
|
+
parts = [
|
177
|
+
f"Access denied: {original_path} is outside base directory and not in allowed directories",
|
178
|
+
f"File absolute path: {expanded_path}",
|
179
|
+
]
|
180
|
+
if base_dir:
|
181
|
+
parts.append(f"Base directory: {base_dir}")
|
182
|
+
if allowed_dirs:
|
183
|
+
parts.append(f"Allowed directories: {allowed_dirs}")
|
184
|
+
parts.append(
|
185
|
+
"Use --allowed-dir to specify additional allowed directories"
|
186
|
+
)
|
187
|
+
return cls("\n".join(parts), error_logged=error_logged)
|
188
|
+
|
189
|
+
def format_with_context(
|
190
|
+
self,
|
191
|
+
original_path: Optional[str] = None,
|
192
|
+
expanded_path: Optional[str] = None,
|
193
|
+
base_dir: Optional[str] = None,
|
194
|
+
allowed_dirs: Optional[List[str]] = None,
|
195
|
+
) -> str:
|
196
|
+
"""Format error message with additional context.
|
197
|
+
|
198
|
+
Args:
|
199
|
+
original_path: Optional original path as provided by user
|
200
|
+
expanded_path: Optional expanded absolute path
|
201
|
+
base_dir: Optional base directory
|
202
|
+
allowed_dirs: Optional list of allowed directories
|
203
|
+
|
204
|
+
Returns:
|
205
|
+
Formatted error message with context
|
206
|
+
"""
|
207
|
+
parts = [self.message]
|
208
|
+
if original_path and expanded_path and original_path != expanded_path:
|
209
|
+
parts.append(f"Original path: {original_path}")
|
210
|
+
parts.append(f"Expanded path: {expanded_path}")
|
211
|
+
if base_dir:
|
212
|
+
parts.append(f"Base directory: {base_dir}")
|
213
|
+
if allowed_dirs:
|
214
|
+
parts.append(f"Allowed directories: {allowed_dirs}")
|
215
|
+
if not any(
|
216
|
+
p.endswith(
|
217
|
+
"Use --allowed-dir to specify additional allowed directories"
|
218
|
+
)
|
219
|
+
for p in parts
|
220
|
+
):
|
221
|
+
parts.append(
|
222
|
+
"Use --allowed-dir to specify additional allowed directories"
|
223
|
+
)
|
224
|
+
return "\n".join(parts)
|
225
|
+
|
226
|
+
@classmethod
|
227
|
+
def wrap_error(
|
228
|
+
cls, context: str, original: "PathSecurityError"
|
229
|
+
) -> "PathSecurityError":
|
230
|
+
"""Wrap an error with additional context while preserving attributes.
|
231
|
+
|
232
|
+
Args:
|
233
|
+
context: Additional context to add to the error message
|
234
|
+
original: The original PathSecurityError to wrap
|
235
|
+
|
236
|
+
Returns:
|
237
|
+
PathSecurityError with additional context and preserved attributes
|
238
|
+
"""
|
239
|
+
base_message = str(original)
|
240
|
+
message = f"{context}: {base_message}"
|
241
|
+
return cls(message, error_logged=original.error_logged, wrapped=True)
|
242
|
+
|
243
|
+
|
244
|
+
class TaskTemplateError(CLIError):
|
245
|
+
"""Base class for task template-related errors."""
|
246
|
+
|
247
|
+
pass
|
248
|
+
|
249
|
+
|
250
|
+
class TaskTemplateSyntaxError(TaskTemplateError):
|
251
|
+
"""Raised when a task template has invalid syntax."""
|
252
|
+
|
253
|
+
pass
|
254
|
+
|
255
|
+
|
256
|
+
class TaskTemplateVariableError(TaskTemplateError):
|
257
|
+
"""Raised when a task template uses undefined variables."""
|
258
|
+
|
259
|
+
pass
|
260
|
+
|
261
|
+
|
262
|
+
class TemplateValidationError(TaskTemplateError):
|
263
|
+
"""Raised when template validation fails."""
|
264
|
+
|
265
|
+
pass
|
266
|
+
|
267
|
+
|
268
|
+
class SystemPromptError(TaskTemplateError):
|
269
|
+
"""Raised when there are issues with system prompt loading or processing."""
|
270
|
+
|
271
|
+
pass
|
272
|
+
|
273
|
+
|
274
|
+
class SchemaError(CLIError):
|
275
|
+
"""Base class for schema-related errors."""
|
276
|
+
|
277
|
+
pass
|
278
|
+
|
279
|
+
|
280
|
+
class SchemaFileError(SchemaError):
|
281
|
+
"""Raised when a schema file is invalid or inaccessible."""
|
282
|
+
|
283
|
+
pass
|
284
|
+
|
285
|
+
|
286
|
+
class SchemaValidationError(SchemaError):
|
287
|
+
"""Raised when a schema fails validation."""
|
288
|
+
|
289
|
+
pass
|
290
|
+
|
291
|
+
|
292
|
+
class ModelCreationError(SchemaError):
|
293
|
+
"""Base class for model creation errors."""
|
294
|
+
|
295
|
+
pass
|
296
|
+
|
297
|
+
|
298
|
+
class FieldDefinitionError(ModelCreationError):
|
299
|
+
"""Raised when field definition fails."""
|
300
|
+
|
301
|
+
def __init__(self, field_name: str, field_type: str, error: str):
|
302
|
+
self.field_name = field_name
|
303
|
+
self.field_type = field_type
|
304
|
+
super().__init__(
|
305
|
+
f"Failed to define field '{field_name}' of type '{field_type}': {error}"
|
306
|
+
)
|
307
|
+
|
308
|
+
|
309
|
+
class NestedModelError(ModelCreationError):
|
310
|
+
"""Raised when nested model creation fails."""
|
311
|
+
|
312
|
+
def __init__(self, model_name: str, parent_field: str, error: str):
|
313
|
+
self.model_name = model_name
|
314
|
+
self.parent_field = parent_field
|
315
|
+
super().__init__(
|
316
|
+
f"Failed to create nested model '{model_name}' for field '{parent_field}': {error}"
|
317
|
+
)
|
318
|
+
|
319
|
+
|
320
|
+
class ModelValidationError(ModelCreationError):
|
321
|
+
"""Raised when model validation fails."""
|
322
|
+
|
323
|
+
def __init__(self, model_name: str, validation_errors: List[str]):
|
324
|
+
self.model_name = model_name
|
325
|
+
self.validation_errors = validation_errors
|
326
|
+
super().__init__(
|
327
|
+
f"Model '{model_name}' validation failed:\n"
|
328
|
+
+ "\n".join(validation_errors)
|
329
|
+
)
|
ostruct/cli/file_info.py
ADDED
@@ -0,0 +1,316 @@
|
|
1
|
+
"""FileInfo class for representing file metadata and content."""
|
2
|
+
|
3
|
+
import hashlib
|
4
|
+
import logging
|
5
|
+
import os
|
6
|
+
from typing import Any, Optional
|
7
|
+
|
8
|
+
from .errors import FileNotFoundError, PathSecurityError
|
9
|
+
from .security import SecurityManager
|
10
|
+
|
11
|
+
logger = logging.getLogger(__name__)
|
12
|
+
|
13
|
+
|
14
|
+
class FileInfo:
|
15
|
+
"""Represents a file with metadata and content.
|
16
|
+
|
17
|
+
This class provides access to file metadata (path, size, etc.) and content,
|
18
|
+
with caching support for efficient access.
|
19
|
+
|
20
|
+
Args:
|
21
|
+
path: Path to the file
|
22
|
+
security_manager: Security manager to use for path validation
|
23
|
+
content: Optional cached content
|
24
|
+
encoding: Optional cached encoding
|
25
|
+
hash_value: Optional cached hash value
|
26
|
+
"""
|
27
|
+
|
28
|
+
def __init__(
|
29
|
+
self,
|
30
|
+
path: str,
|
31
|
+
security_manager: SecurityManager,
|
32
|
+
content: Optional[str] = None,
|
33
|
+
encoding: Optional[str] = None,
|
34
|
+
hash_value: Optional[str] = None,
|
35
|
+
) -> None:
|
36
|
+
"""Initialize FileInfo instance.
|
37
|
+
|
38
|
+
Args:
|
39
|
+
path: Path to the file
|
40
|
+
security_manager: Security manager to use for path validation
|
41
|
+
content: Optional cached content
|
42
|
+
encoding: Optional cached encoding
|
43
|
+
hash_value: Optional cached hash value
|
44
|
+
|
45
|
+
Raises:
|
46
|
+
FileNotFoundError: If the file does not exist
|
47
|
+
PathSecurityError: If the path is not allowed
|
48
|
+
"""
|
49
|
+
# Validate path
|
50
|
+
if not path:
|
51
|
+
raise ValueError("Path cannot be empty")
|
52
|
+
|
53
|
+
# Initialize private attributes
|
54
|
+
self.__path = os.path.expanduser(os.path.expandvars(path))
|
55
|
+
self.__security_manager = security_manager
|
56
|
+
self.__content = content
|
57
|
+
self.__encoding = encoding
|
58
|
+
self.__hash = hash_value
|
59
|
+
self.__size: Optional[int] = None
|
60
|
+
self.__mtime: Optional[float] = None
|
61
|
+
|
62
|
+
# First check if file exists
|
63
|
+
abs_path = os.path.abspath(self.__path)
|
64
|
+
if not os.path.exists(abs_path):
|
65
|
+
raise FileNotFoundError(f"File not found: {path}")
|
66
|
+
if not os.path.isfile(abs_path):
|
67
|
+
raise FileNotFoundError(f"Path is not a file: {path}")
|
68
|
+
|
69
|
+
# Then validate security
|
70
|
+
try:
|
71
|
+
# This will raise PathSecurityError if path is not allowed
|
72
|
+
self.abs_path
|
73
|
+
except PathSecurityError:
|
74
|
+
raise
|
75
|
+
except Exception as e:
|
76
|
+
raise FileNotFoundError(f"Invalid file path: {e}")
|
77
|
+
|
78
|
+
# If content/encoding weren't provided, read them now
|
79
|
+
if self.__content is None or self.__encoding is None:
|
80
|
+
self._read_file()
|
81
|
+
|
82
|
+
@property
|
83
|
+
def path(self) -> str:
|
84
|
+
"""Get the relative path of the file."""
|
85
|
+
# If original path was relative, keep it relative
|
86
|
+
if not os.path.isabs(self.__path):
|
87
|
+
try:
|
88
|
+
base_dir = self.__security_manager.base_dir
|
89
|
+
abs_path = self.abs_path
|
90
|
+
return os.path.relpath(abs_path, base_dir)
|
91
|
+
except ValueError:
|
92
|
+
pass
|
93
|
+
return self.__path
|
94
|
+
|
95
|
+
@path.setter
|
96
|
+
def path(self, value: str) -> None:
|
97
|
+
"""Prevent setting path directly."""
|
98
|
+
raise AttributeError("Cannot modify path directly")
|
99
|
+
|
100
|
+
@property
|
101
|
+
def abs_path(self) -> str:
|
102
|
+
"""Get the absolute path of the file.
|
103
|
+
|
104
|
+
Returns:
|
105
|
+
str: The absolute path of the file.
|
106
|
+
|
107
|
+
Raises:
|
108
|
+
PathSecurityError: If the path is not allowed.
|
109
|
+
"""
|
110
|
+
# Get the resolved absolute path through security manager
|
111
|
+
resolved = self.__security_manager.resolve_path(self.__path)
|
112
|
+
|
113
|
+
# Always return absolute path
|
114
|
+
return str(resolved)
|
115
|
+
|
116
|
+
@property
|
117
|
+
def extension(self) -> str:
|
118
|
+
"""Get file extension without dot."""
|
119
|
+
return os.path.splitext(self.__path)[1].lstrip(".")
|
120
|
+
|
121
|
+
@property
|
122
|
+
def name(self) -> str:
|
123
|
+
"""Get the filename without directory path."""
|
124
|
+
return os.path.basename(self.__path)
|
125
|
+
|
126
|
+
@name.setter
|
127
|
+
def name(self, value: str) -> None:
|
128
|
+
"""Prevent setting name directly."""
|
129
|
+
raise AttributeError("Cannot modify name directly")
|
130
|
+
|
131
|
+
@property
|
132
|
+
def size(self) -> Optional[int]:
|
133
|
+
"""Get file size in bytes."""
|
134
|
+
if self.__size is None:
|
135
|
+
try:
|
136
|
+
self.__size = os.path.getsize(self.abs_path)
|
137
|
+
except OSError:
|
138
|
+
logger.warning("Could not get size for %s", self.__path)
|
139
|
+
return None
|
140
|
+
return self.__size
|
141
|
+
|
142
|
+
@size.setter
|
143
|
+
def size(self, value: int) -> None:
|
144
|
+
"""Prevent setting size directly."""
|
145
|
+
raise AttributeError("Cannot modify size directly")
|
146
|
+
|
147
|
+
@property
|
148
|
+
def mtime(self) -> Optional[float]:
|
149
|
+
"""Get file modification time as Unix timestamp."""
|
150
|
+
if self.__mtime is None:
|
151
|
+
try:
|
152
|
+
self.__mtime = os.path.getmtime(self.abs_path)
|
153
|
+
except OSError:
|
154
|
+
logger.warning("Could not get mtime for %s", self.__path)
|
155
|
+
return None
|
156
|
+
return self.__mtime
|
157
|
+
|
158
|
+
@mtime.setter
|
159
|
+
def mtime(self, value: float) -> None:
|
160
|
+
"""Prevent setting mtime directly."""
|
161
|
+
raise AttributeError("Cannot modify mtime directly")
|
162
|
+
|
163
|
+
@property
|
164
|
+
def content(self) -> str:
|
165
|
+
"""Get the content of the file."""
|
166
|
+
assert (
|
167
|
+
self.__content is not None
|
168
|
+
), "Content should be initialized in constructor"
|
169
|
+
return self.__content
|
170
|
+
|
171
|
+
@content.setter
|
172
|
+
def content(self, value: str) -> None:
|
173
|
+
"""Prevent setting content directly."""
|
174
|
+
raise AttributeError("Cannot modify content directly")
|
175
|
+
|
176
|
+
@property
|
177
|
+
def encoding(self) -> str:
|
178
|
+
"""Get the encoding of the file."""
|
179
|
+
assert (
|
180
|
+
self.__encoding is not None
|
181
|
+
), "Encoding should be initialized in constructor"
|
182
|
+
return self.__encoding
|
183
|
+
|
184
|
+
@encoding.setter
|
185
|
+
def encoding(self, value: str) -> None:
|
186
|
+
"""Prevent setting encoding directly."""
|
187
|
+
raise AttributeError("Cannot modify encoding directly")
|
188
|
+
|
189
|
+
@property
|
190
|
+
def hash(self) -> Optional[str]:
|
191
|
+
"""Get SHA-256 hash of file content."""
|
192
|
+
if self.__hash is None and self.__content is not None:
|
193
|
+
self.__hash = hashlib.sha256(
|
194
|
+
self.__content.encode("utf-8")
|
195
|
+
).hexdigest()
|
196
|
+
return self.__hash
|
197
|
+
|
198
|
+
@hash.setter
|
199
|
+
def hash(self, value: str) -> None:
|
200
|
+
"""Prevent setting hash directly."""
|
201
|
+
raise AttributeError("Cannot modify hash directly")
|
202
|
+
|
203
|
+
def _read_file(self) -> None:
|
204
|
+
"""Read file content and encoding from disk."""
|
205
|
+
try:
|
206
|
+
with open(self.abs_path, "rb") as f:
|
207
|
+
raw_content = f.read()
|
208
|
+
except FileNotFoundError as e:
|
209
|
+
raise FileNotFoundError(f"File not found: {self.__path}") from e
|
210
|
+
except OSError as e:
|
211
|
+
raise FileNotFoundError(
|
212
|
+
f"Could not read file {self.__path}: {e}"
|
213
|
+
) from e
|
214
|
+
|
215
|
+
# Try UTF-8 first
|
216
|
+
try:
|
217
|
+
self.__content = raw_content.decode("utf-8")
|
218
|
+
self.__encoding = "utf-8"
|
219
|
+
return
|
220
|
+
except UnicodeDecodeError:
|
221
|
+
pass
|
222
|
+
|
223
|
+
# Fall back to system default encoding
|
224
|
+
try:
|
225
|
+
self.__content = raw_content.decode()
|
226
|
+
self.__encoding = "system"
|
227
|
+
return
|
228
|
+
except UnicodeDecodeError as e:
|
229
|
+
raise ValueError(
|
230
|
+
f"Could not decode file {self.__path}: {e}"
|
231
|
+
) from e
|
232
|
+
|
233
|
+
def update_cache(
|
234
|
+
self,
|
235
|
+
content: Optional[str] = None,
|
236
|
+
encoding: Optional[str] = None,
|
237
|
+
hash_value: Optional[str] = None,
|
238
|
+
) -> None:
|
239
|
+
"""Update cached values.
|
240
|
+
|
241
|
+
Args:
|
242
|
+
content: New content to cache
|
243
|
+
encoding: New encoding to cache
|
244
|
+
hash_value: New hash value to cache
|
245
|
+
"""
|
246
|
+
if content is not None:
|
247
|
+
self.__content = content
|
248
|
+
if encoding is not None:
|
249
|
+
self.__encoding = encoding
|
250
|
+
if hash_value is not None:
|
251
|
+
self.__hash = hash_value
|
252
|
+
|
253
|
+
@classmethod
|
254
|
+
def from_path(
|
255
|
+
cls, path: str, security_manager: SecurityManager
|
256
|
+
) -> "FileInfo":
|
257
|
+
"""Create FileInfo instance from path.
|
258
|
+
|
259
|
+
Args:
|
260
|
+
path: Path to file
|
261
|
+
security_manager: Security manager for path validation
|
262
|
+
|
263
|
+
Returns:
|
264
|
+
FileInfo instance
|
265
|
+
|
266
|
+
Raises:
|
267
|
+
FileNotFoundError: If file does not exist
|
268
|
+
PathSecurityError: If path is not allowed
|
269
|
+
"""
|
270
|
+
return cls(path, security_manager)
|
271
|
+
|
272
|
+
def __str__(self) -> str:
|
273
|
+
"""String representation showing path."""
|
274
|
+
return f"FileInfo({self.__path})"
|
275
|
+
|
276
|
+
def __repr__(self) -> str:
|
277
|
+
"""Detailed representation."""
|
278
|
+
return (
|
279
|
+
f"FileInfo(path={self.__path!r}, "
|
280
|
+
f"size={self.size!r}, "
|
281
|
+
f"encoding={self.encoding!r}, "
|
282
|
+
f"hash={self.hash!r})"
|
283
|
+
)
|
284
|
+
|
285
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
286
|
+
"""Control attribute modification.
|
287
|
+
|
288
|
+
Internal methods can modify private attributes, but external access is prevented.
|
289
|
+
"""
|
290
|
+
# Allow setting private attributes from internal methods
|
291
|
+
if name.startswith("_FileInfo__") and self._is_internal_call():
|
292
|
+
object.__setattr__(self, name, value)
|
293
|
+
return
|
294
|
+
|
295
|
+
# Prevent setting other attributes
|
296
|
+
raise AttributeError(f"Cannot modify {name} directly")
|
297
|
+
|
298
|
+
def _is_internal_call(self) -> bool:
|
299
|
+
"""Check if the call is from an internal method."""
|
300
|
+
import inspect
|
301
|
+
|
302
|
+
frame = inspect.currentframe()
|
303
|
+
try:
|
304
|
+
# Get the caller's frame (2 frames up: _is_internal_call -> __setattr__ -> caller)
|
305
|
+
caller = frame.f_back.f_back if frame and frame.f_back else None
|
306
|
+
if not caller:
|
307
|
+
return False
|
308
|
+
|
309
|
+
# Check if the caller is a method of this class
|
310
|
+
return (
|
311
|
+
caller.f_code.co_name in type(self).__dict__
|
312
|
+
and "self" in caller.f_locals
|
313
|
+
and caller.f_locals["self"] is self
|
314
|
+
)
|
315
|
+
finally:
|
316
|
+
del frame # Avoid reference cycles
|