ostruct-cli 0.1.4__py3-none-any.whl → 0.3.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 +2 -2
- ostruct/cli/cli.py +513 -599
- ostruct/cli/click_options.py +248 -0
- ostruct/cli/errors.py +297 -139
- ostruct/cli/file_info.py +82 -11
- ostruct/cli/file_list.py +57 -1
- ostruct/cli/file_utils.py +99 -8
- ostruct/cli/security.py +793 -140
- ostruct/cli/security_types.py +3 -6
- ostruct/cli/template_extensions.py +1 -1
- ostruct/cli/template_io.py +4 -2
- ostruct/cli/template_rendering.py +0 -8
- ostruct/cli/template_validation.py +1 -1
- {ostruct_cli-0.1.4.dist-info → ostruct_cli-0.3.0.dist-info}/METADATA +4 -2
- ostruct_cli-0.3.0.dist-info/RECORD +28 -0
- ostruct_cli-0.1.4.dist-info/RECORD +0 -27
- {ostruct_cli-0.1.4.dist-info → ostruct_cli-0.3.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.1.4.dist-info → ostruct_cli-0.3.0.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.1.4.dist-info → ostruct_cli-0.3.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/errors.py
CHANGED
@@ -1,13 +1,47 @@
|
|
1
1
|
"""Custom error classes for CLI error handling."""
|
2
2
|
|
3
|
+
import os
|
3
4
|
from pathlib import Path
|
4
|
-
from typing import List, Optional
|
5
|
+
from typing import Any, Dict, List, Optional, TextIO, cast
|
5
6
|
|
7
|
+
import click
|
6
8
|
|
7
|
-
|
9
|
+
|
10
|
+
class CLIError(click.ClickException):
|
8
11
|
"""Base class for all CLI errors."""
|
9
12
|
|
10
|
-
|
13
|
+
def __init__(self, message: str, context: Optional[Dict[str, Any]] = None):
|
14
|
+
super().__init__(message)
|
15
|
+
self.context = context or {}
|
16
|
+
self._has_been_logged = False # Use underscore for private attribute
|
17
|
+
|
18
|
+
@property
|
19
|
+
def has_been_logged(self) -> bool:
|
20
|
+
"""Whether this error has been logged."""
|
21
|
+
return self._has_been_logged
|
22
|
+
|
23
|
+
@has_been_logged.setter
|
24
|
+
def has_been_logged(self, value: bool) -> None:
|
25
|
+
"""Set whether this error has been logged."""
|
26
|
+
self._has_been_logged = value
|
27
|
+
|
28
|
+
def show(self, file: Optional[TextIO] = None) -> None:
|
29
|
+
"""Show the error message with optional context."""
|
30
|
+
if file is None:
|
31
|
+
file = cast(TextIO, click.get_text_stream("stderr"))
|
32
|
+
|
33
|
+
# Format message with context if available
|
34
|
+
if self.context:
|
35
|
+
context_str = "\n".join(
|
36
|
+
f" {k}: {v}" for k, v in self.context.items()
|
37
|
+
)
|
38
|
+
click.secho(
|
39
|
+
f"Error: {self.message}\nContext:\n{context_str}",
|
40
|
+
fg="red",
|
41
|
+
file=file,
|
42
|
+
)
|
43
|
+
else:
|
44
|
+
click.secho(f"Error: {self.message}", fg="red", file=file)
|
11
45
|
|
12
46
|
|
13
47
|
class VariableError(CLIError):
|
@@ -28,62 +62,167 @@ class VariableValueError(VariableError):
|
|
28
62
|
pass
|
29
63
|
|
30
64
|
|
31
|
-
class InvalidJSONError(
|
65
|
+
class InvalidJSONError(CLIError):
|
32
66
|
"""Raised when JSON parsing fails for a variable value."""
|
33
67
|
|
34
|
-
|
68
|
+
def __init__(
|
69
|
+
self,
|
70
|
+
message: str,
|
71
|
+
source: Optional[str] = None,
|
72
|
+
context: Optional[Dict[str, Any]] = None,
|
73
|
+
):
|
74
|
+
context = context or {}
|
75
|
+
if source:
|
76
|
+
context["source"] = source
|
77
|
+
super().__init__(message, context)
|
35
78
|
|
36
79
|
|
37
80
|
class PathError(CLIError):
|
38
81
|
"""Base class for path-related errors."""
|
39
82
|
|
40
|
-
|
83
|
+
def __init__(
|
84
|
+
self, message: str, path: str, context: Optional[Dict[str, Any]] = None
|
85
|
+
):
|
86
|
+
context = context or {}
|
87
|
+
context["path"] = path
|
88
|
+
super().__init__(message, context)
|
41
89
|
|
42
90
|
|
43
91
|
class FileNotFoundError(PathError):
|
44
92
|
"""Raised when a specified file does not exist."""
|
45
93
|
|
46
|
-
|
94
|
+
def __init__(self, path: str, context: Optional[Dict[str, Any]] = None):
|
95
|
+
# Use path directly as the message without prepending "File not found: "
|
96
|
+
super().__init__(path, path, context)
|
47
97
|
|
48
98
|
|
49
99
|
class DirectoryNotFoundError(PathError):
|
50
100
|
"""Raised when a specified directory does not exist."""
|
51
101
|
|
52
|
-
|
102
|
+
def __init__(self, path: str, context: Optional[Dict[str, Any]] = None):
|
103
|
+
# Use path directly as the message without prepending "Directory not found: "
|
104
|
+
super().__init__(path, path, context)
|
53
105
|
|
54
106
|
|
55
|
-
class PathSecurityError(
|
107
|
+
class PathSecurityError(PathError):
|
56
108
|
"""Exception raised when file access is denied due to security constraints.
|
57
109
|
|
58
110
|
Attributes:
|
59
111
|
message: The error message with full context
|
60
|
-
error_logged: Whether this error has already been logged
|
61
112
|
wrapped: Whether this error has been wrapped by another error
|
62
113
|
"""
|
63
114
|
|
64
115
|
def __init__(
|
65
|
-
self,
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
""
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
116
|
+
self,
|
117
|
+
message: str,
|
118
|
+
path: Optional[str] = None,
|
119
|
+
context: Optional[Dict[str, Any]] = None,
|
120
|
+
error_logged: bool = False,
|
121
|
+
):
|
122
|
+
path = path or "unknown" # Provide default path if none given
|
123
|
+
context = context or {}
|
124
|
+
context["has_been_logged"] = (
|
125
|
+
error_logged # Store in context to match parent behavior
|
126
|
+
)
|
127
|
+
context["wrapped"] = False # Initialize wrapped state
|
128
|
+
super().__init__(message, path, context)
|
129
|
+
|
130
|
+
def __str__(self) -> str:
|
131
|
+
"""Return string representation with allowed directories if present."""
|
132
|
+
base = super().__str__()
|
133
|
+
if self.context and "allowed_dirs" in self.context:
|
134
|
+
allowed = self.context["allowed_dirs"]
|
135
|
+
if allowed: # Only add if there are actually allowed dirs
|
136
|
+
return f"{base} (allowed directories: {', '.join(allowed)})"
|
137
|
+
return base
|
78
138
|
|
79
139
|
@property
|
80
140
|
def has_been_logged(self) -> bool:
|
81
|
-
"""
|
82
|
-
return self.
|
141
|
+
"""Whether this error has been logged."""
|
142
|
+
return bool(self.context.get("has_been_logged", False))
|
83
143
|
|
84
|
-
|
85
|
-
|
86
|
-
|
144
|
+
@has_been_logged.setter
|
145
|
+
def has_been_logged(self, value: bool) -> None:
|
146
|
+
"""Set whether this error has been logged."""
|
147
|
+
self.context["has_been_logged"] = value
|
148
|
+
|
149
|
+
@property
|
150
|
+
def error_logged(self) -> bool:
|
151
|
+
"""Alias for has_been_logged for backward compatibility."""
|
152
|
+
return self.has_been_logged
|
153
|
+
|
154
|
+
@error_logged.setter
|
155
|
+
def error_logged(self, value: bool) -> None:
|
156
|
+
"""Alias for has_been_logged for backward compatibility."""
|
157
|
+
self.has_been_logged = value
|
158
|
+
|
159
|
+
@property
|
160
|
+
def wrapped(self) -> bool:
|
161
|
+
"""Whether this error has been wrapped by another error."""
|
162
|
+
return bool(self.context.get("wrapped", False))
|
163
|
+
|
164
|
+
@wrapped.setter
|
165
|
+
def wrapped(self, value: bool) -> None:
|
166
|
+
"""Set whether this error has been wrapped."""
|
167
|
+
self.context["wrapped"] = value
|
168
|
+
|
169
|
+
@staticmethod
|
170
|
+
def _format_allowed_dirs(allowed_dirs: List[str]) -> str:
|
171
|
+
"""Format allowed directories as a list representation."""
|
172
|
+
return f"[{', '.join(repr(d) for d in allowed_dirs)}]"
|
173
|
+
|
174
|
+
@staticmethod
|
175
|
+
def _create_error_message(
|
176
|
+
path: str, base_dir: Optional[str] = None
|
177
|
+
) -> str:
|
178
|
+
"""Create a standardized error message."""
|
179
|
+
if base_dir:
|
180
|
+
rel_path = os.path.relpath(path, base_dir)
|
181
|
+
return f"Access denied: {rel_path} is outside base directory and not in allowed directories"
|
182
|
+
return f"Access denied: {path} is outside base directory and not in allowed directories"
|
183
|
+
|
184
|
+
@classmethod
|
185
|
+
def from_expanded_paths(
|
186
|
+
cls,
|
187
|
+
original_path: str,
|
188
|
+
expanded_path: str,
|
189
|
+
base_dir: Optional[str] = None,
|
190
|
+
allowed_dirs: Optional[List[str]] = None,
|
191
|
+
error_logged: bool = False,
|
192
|
+
) -> "PathSecurityError":
|
193
|
+
"""Create error with expanded path context."""
|
194
|
+
message = f"Access denied: {original_path} is outside base directory and not in allowed directories"
|
195
|
+
if expanded_path != original_path:
|
196
|
+
message += f" (expanded to {expanded_path})"
|
197
|
+
|
198
|
+
context = {
|
199
|
+
"original_path": original_path,
|
200
|
+
"expanded_path": expanded_path,
|
201
|
+
"has_been_logged": error_logged,
|
202
|
+
}
|
203
|
+
if base_dir:
|
204
|
+
context["base_dir"] = base_dir
|
205
|
+
if allowed_dirs:
|
206
|
+
context["allowed_dirs"] = allowed_dirs
|
207
|
+
|
208
|
+
# Format full message with all context
|
209
|
+
parts = [message]
|
210
|
+
if base_dir:
|
211
|
+
parts.append(f"Base directory: {base_dir}")
|
212
|
+
if allowed_dirs:
|
213
|
+
parts.append(
|
214
|
+
f"Allowed directories: {cls._format_allowed_dirs(allowed_dirs)}"
|
215
|
+
)
|
216
|
+
parts.append(
|
217
|
+
"Use --allowed-dir to specify additional allowed directories"
|
218
|
+
)
|
219
|
+
|
220
|
+
return cls(
|
221
|
+
"\n".join(parts),
|
222
|
+
path=original_path,
|
223
|
+
context=context,
|
224
|
+
error_logged=error_logged,
|
225
|
+
)
|
87
226
|
|
88
227
|
@classmethod
|
89
228
|
def access_denied(
|
@@ -92,20 +231,12 @@ class PathSecurityError(Exception):
|
|
92
231
|
reason: Optional[str] = None,
|
93
232
|
error_logged: bool = False,
|
94
233
|
) -> "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
|
-
"""
|
234
|
+
"""Create access denied error."""
|
105
235
|
msg = f"Access denied: {path}"
|
106
236
|
if reason:
|
107
237
|
msg += f" - {reason}"
|
108
|
-
|
238
|
+
msg += " is outside base directory and not in allowed directories"
|
239
|
+
return cls(msg, path=str(path), error_logged=error_logged)
|
109
240
|
|
110
241
|
@classmethod
|
111
242
|
def outside_allowed(
|
@@ -114,77 +245,22 @@ class PathSecurityError(Exception):
|
|
114
245
|
base_dir: Optional[Path] = None,
|
115
246
|
error_logged: bool = False,
|
116
247
|
) -> "PathSecurityError":
|
117
|
-
"""Create error for path outside allowed directories.
|
118
|
-
|
119
|
-
|
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
|
-
]
|
248
|
+
"""Create error for path outside allowed directories."""
|
249
|
+
msg = f"Access denied: {path} is outside base directory and not in allowed directories"
|
250
|
+
context = {}
|
130
251
|
if base_dir:
|
131
|
-
|
132
|
-
|
133
|
-
|
252
|
+
context["base_directory"] = str(base_dir)
|
253
|
+
return cls(
|
254
|
+
msg, path=str(path), context=context, error_logged=error_logged
|
134
255
|
)
|
135
|
-
return cls("\n".join(parts), error_logged=error_logged)
|
136
256
|
|
137
257
|
@classmethod
|
138
258
|
def traversal_attempt(
|
139
259
|
cls, path: Path, error_logged: bool = False
|
140
260
|
) -> "PathSecurityError":
|
141
|
-
"""Create error for directory traversal attempt.
|
142
|
-
|
143
|
-
|
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)
|
261
|
+
"""Create error for directory traversal attempt."""
|
262
|
+
msg = f"Access denied: {path} - directory traversal not allowed"
|
263
|
+
return cls(msg, path=str(path), error_logged=error_logged)
|
188
264
|
|
189
265
|
def format_with_context(
|
190
266
|
self,
|
@@ -193,17 +269,7 @@ class PathSecurityError(Exception):
|
|
193
269
|
base_dir: Optional[str] = None,
|
194
270
|
allowed_dirs: Optional[List[str]] = None,
|
195
271
|
) -> 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
|
-
"""
|
272
|
+
"""Format error message with additional context."""
|
207
273
|
parts = [self.message]
|
208
274
|
if original_path and expanded_path and original_path != expanded_path:
|
209
275
|
parts.append(f"Original path: {original_path}")
|
@@ -211,34 +277,30 @@ class PathSecurityError(Exception):
|
|
211
277
|
if base_dir:
|
212
278
|
parts.append(f"Base directory: {base_dir}")
|
213
279
|
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
280
|
parts.append(
|
222
|
-
"
|
281
|
+
f"Allowed directories: {self._format_allowed_dirs(allowed_dirs)}"
|
223
282
|
)
|
283
|
+
parts.append(
|
284
|
+
"Use --allowed-dir to specify additional allowed directories"
|
285
|
+
)
|
224
286
|
return "\n".join(parts)
|
225
287
|
|
226
288
|
@classmethod
|
227
289
|
def wrap_error(
|
228
290
|
cls, context: str, original: "PathSecurityError"
|
229
291
|
) -> "PathSecurityError":
|
230
|
-
"""Wrap an error with additional context while preserving attributes.
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
return
|
292
|
+
"""Wrap an error with additional context while preserving attributes."""
|
293
|
+
message = f"{context}: {original.message}"
|
294
|
+
new_context = original.context.copy()
|
295
|
+
new_context["wrapped"] = True # Mark as wrapped
|
296
|
+
error = cls(
|
297
|
+
message,
|
298
|
+
path=original.context.get("path", "unknown"),
|
299
|
+
context=new_context,
|
300
|
+
error_logged=original.has_been_logged,
|
301
|
+
)
|
302
|
+
error.wrapped = True # Ensure wrapped is set through the property
|
303
|
+
return error
|
242
304
|
|
243
305
|
|
244
306
|
class TaskTemplateError(CLIError):
|
@@ -277,19 +339,37 @@ class SchemaError(CLIError):
|
|
277
339
|
pass
|
278
340
|
|
279
341
|
|
280
|
-
class SchemaFileError(
|
342
|
+
class SchemaFileError(CLIError):
|
281
343
|
"""Raised when a schema file is invalid or inaccessible."""
|
282
344
|
|
283
|
-
|
345
|
+
def __init__(
|
346
|
+
self,
|
347
|
+
message: str,
|
348
|
+
schema_path: Optional[str] = None,
|
349
|
+
context: Optional[Dict[str, Any]] = None,
|
350
|
+
):
|
351
|
+
context = context or {}
|
352
|
+
if schema_path:
|
353
|
+
context["schema_path"] = schema_path
|
354
|
+
super().__init__(message, context)
|
284
355
|
|
285
356
|
|
286
|
-
class SchemaValidationError(
|
357
|
+
class SchemaValidationError(CLIError):
|
287
358
|
"""Raised when a schema fails validation."""
|
288
359
|
|
289
|
-
|
360
|
+
def __init__(
|
361
|
+
self,
|
362
|
+
message: str,
|
363
|
+
schema_path: Optional[str] = None,
|
364
|
+
context: Optional[Dict[str, Any]] = None,
|
365
|
+
):
|
366
|
+
context = context or {}
|
367
|
+
if schema_path:
|
368
|
+
context["schema_path"] = schema_path
|
369
|
+
super().__init__(message, context)
|
290
370
|
|
291
371
|
|
292
|
-
class ModelCreationError(
|
372
|
+
class ModelCreationError(CLIError):
|
293
373
|
"""Base class for model creation errors."""
|
294
374
|
|
295
375
|
pass
|
@@ -327,3 +407,81 @@ class ModelValidationError(ModelCreationError):
|
|
327
407
|
f"Model '{model_name}' validation failed:\n"
|
328
408
|
+ "\n".join(validation_errors)
|
329
409
|
)
|
410
|
+
|
411
|
+
|
412
|
+
class ModelNotSupportedError(CLIError):
|
413
|
+
"""Exception raised when a model doesn't support structured output."""
|
414
|
+
|
415
|
+
pass
|
416
|
+
|
417
|
+
|
418
|
+
class ModelVersionError(CLIError):
|
419
|
+
"""Exception raised when a model version is not supported."""
|
420
|
+
|
421
|
+
pass
|
422
|
+
|
423
|
+
|
424
|
+
class StreamInterruptedError(CLIError):
|
425
|
+
"""Exception raised when a stream is interrupted."""
|
426
|
+
|
427
|
+
pass
|
428
|
+
|
429
|
+
|
430
|
+
class StreamBufferError(CLIError):
|
431
|
+
"""Exception raised when there's an error with the stream buffer."""
|
432
|
+
|
433
|
+
pass
|
434
|
+
|
435
|
+
|
436
|
+
class StreamParseError(CLIError):
|
437
|
+
"""Exception raised when there's an error parsing the stream."""
|
438
|
+
|
439
|
+
pass
|
440
|
+
|
441
|
+
|
442
|
+
class APIResponseError(CLIError):
|
443
|
+
"""Exception raised when there's an error with the API response."""
|
444
|
+
|
445
|
+
pass
|
446
|
+
|
447
|
+
|
448
|
+
class EmptyResponseError(CLIError):
|
449
|
+
"""Exception raised when the API returns an empty response."""
|
450
|
+
|
451
|
+
pass
|
452
|
+
|
453
|
+
|
454
|
+
class InvalidResponseFormatError(CLIError):
|
455
|
+
"""Exception raised when the API response format is invalid."""
|
456
|
+
|
457
|
+
pass
|
458
|
+
|
459
|
+
|
460
|
+
class OpenAIClientError(CLIError):
|
461
|
+
"""Exception raised when there's an error with the OpenAI client."""
|
462
|
+
|
463
|
+
pass
|
464
|
+
|
465
|
+
|
466
|
+
# Export public API
|
467
|
+
__all__ = [
|
468
|
+
"CLIError",
|
469
|
+
"VariableError",
|
470
|
+
"PathError",
|
471
|
+
"PathSecurityError",
|
472
|
+
"FileNotFoundError",
|
473
|
+
"DirectoryNotFoundError",
|
474
|
+
"SchemaValidationError",
|
475
|
+
"SchemaFileError",
|
476
|
+
"InvalidJSONError",
|
477
|
+
"ModelCreationError",
|
478
|
+
"ModelNotSupportedError",
|
479
|
+
"ModelVersionError",
|
480
|
+
"StreamInterruptedError",
|
481
|
+
"StreamBufferError",
|
482
|
+
"StreamParseError",
|
483
|
+
"APIResponseError",
|
484
|
+
"EmptyResponseError",
|
485
|
+
"InvalidResponseFormatError",
|
486
|
+
"OpenAIClientError",
|
487
|
+
]
|
ostruct/cli/file_info.py
CHANGED
@@ -46,6 +46,8 @@ class FileInfo:
|
|
46
46
|
FileNotFoundError: If the file does not exist
|
47
47
|
PathSecurityError: If the path is not allowed
|
48
48
|
"""
|
49
|
+
logger.debug("Creating FileInfo for path: %s", path)
|
50
|
+
|
49
51
|
# Validate path
|
50
52
|
if not path:
|
51
53
|
raise ValueError("Path cannot be empty")
|
@@ -59,24 +61,43 @@ class FileInfo:
|
|
59
61
|
self.__size: Optional[int] = None
|
60
62
|
self.__mtime: Optional[float] = None
|
61
63
|
|
62
|
-
# First
|
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
|
64
|
+
# First validate security and resolve path
|
70
65
|
try:
|
71
66
|
# This will raise PathSecurityError if path is not allowed
|
72
|
-
self.
|
67
|
+
resolved_path = self.__security_manager.resolve_path(self.__path)
|
68
|
+
logger.debug(
|
69
|
+
"Security-resolved path for %s: %s", path, resolved_path
|
70
|
+
)
|
71
|
+
|
72
|
+
# Now check if the file exists and is accessible
|
73
|
+
if not resolved_path.exists():
|
74
|
+
# Use the original path in the error message
|
75
|
+
raise FileNotFoundError(
|
76
|
+
f"File not found: {os.path.basename(path)}"
|
77
|
+
)
|
78
|
+
|
79
|
+
if not resolved_path.is_file():
|
80
|
+
raise FileNotFoundError(
|
81
|
+
f"Not a file: {os.path.basename(path)}"
|
82
|
+
)
|
83
|
+
|
73
84
|
except PathSecurityError:
|
85
|
+
# Re-raise security errors as is
|
74
86
|
raise
|
75
|
-
except
|
76
|
-
raise
|
87
|
+
except FileNotFoundError:
|
88
|
+
# Re-raise file not found errors with simplified message
|
89
|
+
raise FileNotFoundError(
|
90
|
+
f"File not found: {os.path.basename(path)}"
|
91
|
+
)
|
92
|
+
except Exception: # Catch all other exceptions
|
93
|
+
# Convert other errors to FileNotFoundError with simplified message
|
94
|
+
raise FileNotFoundError(
|
95
|
+
f"File not found: {os.path.basename(path)}"
|
96
|
+
)
|
77
97
|
|
78
98
|
# If content/encoding weren't provided, read them now
|
79
99
|
if self.__content is None or self.__encoding is None:
|
100
|
+
logger.debug("Reading content for %s", path)
|
80
101
|
self._read_file()
|
81
102
|
|
82
103
|
@property
|
@@ -200,6 +221,32 @@ class FileInfo:
|
|
200
221
|
"""Prevent setting hash directly."""
|
201
222
|
raise AttributeError("Cannot modify hash directly")
|
202
223
|
|
224
|
+
@property
|
225
|
+
def exists(self) -> bool:
|
226
|
+
"""Check if the file exists.
|
227
|
+
|
228
|
+
Returns:
|
229
|
+
bool: True if the file exists, False otherwise
|
230
|
+
"""
|
231
|
+
try:
|
232
|
+
return os.path.exists(self.abs_path)
|
233
|
+
except (OSError, PathSecurityError):
|
234
|
+
return False
|
235
|
+
|
236
|
+
@property
|
237
|
+
def is_binary(self) -> bool:
|
238
|
+
"""Check if the file appears to be binary.
|
239
|
+
|
240
|
+
Returns:
|
241
|
+
bool: True if the file appears to be binary, False otherwise
|
242
|
+
"""
|
243
|
+
try:
|
244
|
+
with open(self.abs_path, "rb") as f:
|
245
|
+
chunk = f.read(1024)
|
246
|
+
return b"\0" in chunk
|
247
|
+
except (OSError, PathSecurityError):
|
248
|
+
return False
|
249
|
+
|
203
250
|
def _read_file(self) -> None:
|
204
251
|
"""Read file content and encoding from disk."""
|
205
252
|
try:
|
@@ -314,3 +361,27 @@ class FileInfo:
|
|
314
361
|
)
|
315
362
|
finally:
|
316
363
|
del frame # Avoid reference cycles
|
364
|
+
|
365
|
+
def to_dict(self) -> dict[str, Any]:
|
366
|
+
"""Convert file info to a dictionary.
|
367
|
+
|
368
|
+
Returns:
|
369
|
+
Dictionary containing file metadata and content
|
370
|
+
"""
|
371
|
+
# Get file stats
|
372
|
+
stats = os.stat(self.abs_path)
|
373
|
+
|
374
|
+
return {
|
375
|
+
"path": self.path,
|
376
|
+
"abs_path": str(self.abs_path),
|
377
|
+
"exists": self.exists,
|
378
|
+
"size": self.size,
|
379
|
+
"content": self.content,
|
380
|
+
"encoding": self.encoding,
|
381
|
+
"hash": self.hash,
|
382
|
+
"mtime": self.mtime,
|
383
|
+
"mtime_ns": (
|
384
|
+
int(self.mtime * 1e9) if self.mtime is not None else None
|
385
|
+
),
|
386
|
+
"mode": stats.st_mode,
|
387
|
+
}
|