ostruct-cli 0.4.0__py3-none-any.whl → 0.6.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/base_errors.py +183 -0
- ostruct/cli/cli.py +879 -592
- ostruct/cli/click_options.py +320 -202
- ostruct/cli/errors.py +273 -134
- ostruct/cli/exit_codes.py +18 -0
- ostruct/cli/file_info.py +30 -14
- ostruct/cli/file_list.py +4 -10
- ostruct/cli/file_utils.py +43 -35
- ostruct/cli/path_utils.py +32 -4
- ostruct/cli/schema_validation.py +213 -0
- ostruct/cli/security/allowed_checker.py +8 -0
- ostruct/cli/security/base.py +46 -0
- ostruct/cli/security/errors.py +83 -103
- ostruct/cli/security/security_manager.py +22 -9
- ostruct/cli/serialization.py +25 -0
- ostruct/cli/template_filters.py +5 -3
- ostruct/cli/template_rendering.py +46 -22
- ostruct/cli/template_utils.py +12 -4
- ostruct/cli/template_validation.py +26 -8
- ostruct/cli/token_utils.py +43 -0
- ostruct/cli/validators.py +109 -0
- ostruct_cli-0.6.0.dist-info/METADATA +404 -0
- ostruct_cli-0.6.0.dist-info/RECORD +43 -0
- {ostruct_cli-0.4.0.dist-info → ostruct_cli-0.6.0.dist-info}/WHEEL +1 -1
- ostruct_cli-0.4.0.dist-info/METADATA +0 -186
- ostruct_cli-0.4.0.dist-info/RECORD +0 -36
- {ostruct_cli-0.4.0.dist-info → ostruct_cli-0.6.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.4.0.dist-info → ostruct_cli-0.6.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/errors.py
CHANGED
@@ -1,52 +1,17 @@
|
|
1
1
|
"""Custom error classes for CLI error handling."""
|
2
2
|
|
3
|
+
import json
|
3
4
|
import logging
|
4
|
-
from typing import Any, Dict, List, Optional
|
5
|
+
from typing import Any, Dict, List, Optional
|
5
6
|
|
6
|
-
import
|
7
|
-
|
8
|
-
from .security.
|
7
|
+
from .base_errors import CLIError, OstructFileNotFoundError
|
8
|
+
from .exit_codes import ExitCode
|
9
|
+
from .security.base import SecurityErrorBase
|
10
|
+
from .security.errors import SecurityErrorReasons
|
9
11
|
|
10
12
|
logger = logging.getLogger(__name__)
|
11
13
|
|
12
14
|
|
13
|
-
class CLIError(click.ClickException):
|
14
|
-
"""Base class for all CLI errors."""
|
15
|
-
|
16
|
-
def __init__(self, message: str, context: Optional[Dict[str, Any]] = None):
|
17
|
-
super().__init__(message)
|
18
|
-
self.context = context or {}
|
19
|
-
self._has_been_logged = False # Use underscore for private attribute
|
20
|
-
|
21
|
-
@property
|
22
|
-
def has_been_logged(self) -> bool:
|
23
|
-
"""Whether this error has been logged."""
|
24
|
-
return self._has_been_logged
|
25
|
-
|
26
|
-
@has_been_logged.setter
|
27
|
-
def has_been_logged(self, value: bool) -> None:
|
28
|
-
"""Set whether this error has been logged."""
|
29
|
-
self._has_been_logged = value
|
30
|
-
|
31
|
-
def show(self, file: Optional[TextIO] = None) -> None:
|
32
|
-
"""Show the error message with optional context."""
|
33
|
-
if file is None:
|
34
|
-
file = cast(TextIO, click.get_text_stream("stderr"))
|
35
|
-
|
36
|
-
# Format message with context if available
|
37
|
-
if self.context:
|
38
|
-
context_str = "\n".join(
|
39
|
-
f" {k}: {v}" for k, v in self.context.items()
|
40
|
-
)
|
41
|
-
click.secho(
|
42
|
-
f"Error: {self.message}\nContext:\n{context_str}",
|
43
|
-
fg="red",
|
44
|
-
file=file,
|
45
|
-
)
|
46
|
-
else:
|
47
|
-
click.secho(f"Error: {self.message}", fg="red", file=file)
|
48
|
-
|
49
|
-
|
50
15
|
class VariableError(CLIError):
|
51
16
|
"""Base class for variable-related errors."""
|
52
17
|
|
@@ -66,7 +31,7 @@ class VariableValueError(VariableError):
|
|
66
31
|
|
67
32
|
|
68
33
|
class InvalidJSONError(CLIError):
|
69
|
-
"""
|
34
|
+
"""Error raised when JSON is invalid."""
|
70
35
|
|
71
36
|
def __init__(
|
72
37
|
self,
|
@@ -74,29 +39,36 @@ class InvalidJSONError(CLIError):
|
|
74
39
|
source: Optional[str] = None,
|
75
40
|
context: Optional[Dict[str, Any]] = None,
|
76
41
|
):
|
42
|
+
"""Initialize invalid JSON error.
|
43
|
+
|
44
|
+
Args:
|
45
|
+
message: Error message
|
46
|
+
source: Source of invalid JSON
|
47
|
+
context: Additional context for the error
|
48
|
+
"""
|
77
49
|
context = context or {}
|
78
50
|
if source:
|
79
51
|
context["source"] = source
|
80
|
-
super().__init__(
|
52
|
+
super().__init__(
|
53
|
+
message,
|
54
|
+
exit_code=ExitCode.DATA_ERROR,
|
55
|
+
context=context,
|
56
|
+
)
|
81
57
|
|
82
58
|
|
83
59
|
class PathError(CLIError):
|
84
60
|
"""Base class for path-related errors."""
|
85
61
|
|
86
62
|
def __init__(
|
87
|
-
self,
|
63
|
+
self,
|
64
|
+
message: str,
|
65
|
+
path: str,
|
66
|
+
context: Optional[Dict[str, Any]] = None,
|
67
|
+
exit_code: int = ExitCode.FILE_ERROR,
|
88
68
|
):
|
89
69
|
context = context or {}
|
90
70
|
context["path"] = path
|
91
|
-
super().__init__(message, context)
|
92
|
-
|
93
|
-
|
94
|
-
class FileNotFoundError(PathError):
|
95
|
-
"""Raised when a specified file does not exist."""
|
96
|
-
|
97
|
-
def __init__(self, path: str, context: Optional[Dict[str, Any]] = None):
|
98
|
-
# Use path directly as the message without prepending "File not found: "
|
99
|
-
super().__init__(path, path, context)
|
71
|
+
super().__init__(message, context=context, exit_code=exit_code)
|
100
72
|
|
101
73
|
|
102
74
|
class FileReadError(PathError):
|
@@ -116,83 +88,142 @@ class DirectoryNotFoundError(PathError):
|
|
116
88
|
"""Raised when a specified directory does not exist."""
|
117
89
|
|
118
90
|
def __init__(self, path: str, context: Optional[Dict[str, Any]] = None):
|
119
|
-
|
120
|
-
|
121
|
-
|
91
|
+
context = context or {}
|
92
|
+
context.update(
|
93
|
+
{
|
94
|
+
"details": "The specified directory does not exist or cannot be accessed",
|
95
|
+
"troubleshooting": [
|
96
|
+
"Check if the directory exists",
|
97
|
+
"Verify the path spelling is correct",
|
98
|
+
"Check directory permissions",
|
99
|
+
"Ensure parent directories exist",
|
100
|
+
"Use --allowed-dir to specify additional allowed directories",
|
101
|
+
],
|
102
|
+
}
|
103
|
+
)
|
104
|
+
super().__init__(
|
105
|
+
f"Directory not found: {path}", path=path, context=context
|
106
|
+
)
|
122
107
|
|
123
|
-
class PathSecurityError(CLIError, SecurityPathSecurityError):
|
124
|
-
"""CLI wrapper for security package's PathSecurityError.
|
125
108
|
|
126
|
-
|
127
|
-
|
128
|
-
"""
|
109
|
+
class PathSecurityError(SecurityErrorBase):
|
110
|
+
"""Error raised when a path violates security constraints."""
|
129
111
|
|
130
112
|
def __init__(
|
131
113
|
self,
|
132
114
|
message: str,
|
133
115
|
path: Optional[str] = None,
|
134
|
-
context: Optional[Dict[str, Any]] = None,
|
135
116
|
error_logged: bool = False,
|
136
|
-
|
137
|
-
|
117
|
+
wrapped: bool = False,
|
118
|
+
context: Optional[Dict[str, Any]] = None,
|
119
|
+
) -> None:
|
120
|
+
"""Initialize error.
|
138
121
|
|
139
122
|
Args:
|
140
|
-
message:
|
141
|
-
path:
|
142
|
-
|
143
|
-
|
123
|
+
message: Error message
|
124
|
+
path: Path that caused the error
|
125
|
+
error_logged: Whether error has been logged
|
126
|
+
wrapped: Whether this is a wrapped error
|
127
|
+
context: Additional error context
|
144
128
|
"""
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
message,
|
160
|
-
path,
|
161
|
-
self.context,
|
162
|
-
error_logged,
|
163
|
-
)
|
129
|
+
context = context or {}
|
130
|
+
if path is not None:
|
131
|
+
context["path"] = path
|
132
|
+
context.setdefault(
|
133
|
+
"details", "The specified path violates security constraints"
|
134
|
+
)
|
135
|
+
context.setdefault(
|
136
|
+
"troubleshooting",
|
137
|
+
[
|
138
|
+
"Check if the path is within allowed directories",
|
139
|
+
"Use --allowed-dir to specify additional allowed directories",
|
140
|
+
"Verify path permissions",
|
141
|
+
],
|
142
|
+
)
|
164
143
|
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
super().show(file)
|
144
|
+
super().__init__(message, context=context)
|
145
|
+
self._wrapped = wrapped
|
146
|
+
self._error_logged = error_logged
|
169
147
|
|
170
148
|
@property
|
171
|
-
def
|
149
|
+
def error_logged(self) -> bool:
|
172
150
|
"""Whether this error has been logged."""
|
173
|
-
return self.
|
174
|
-
|
175
|
-
@has_been_logged.setter
|
176
|
-
def has_been_logged(self, value: bool) -> None:
|
177
|
-
"""Set whether this error has been logged."""
|
178
|
-
self._has_been_logged = value
|
179
|
-
super().has_been_logged = value # type: ignore[misc]
|
151
|
+
return self._error_logged
|
180
152
|
|
181
153
|
@property
|
182
|
-
def
|
183
|
-
"""Whether this
|
184
|
-
return self.
|
154
|
+
def wrapped(self) -> bool:
|
155
|
+
"""Whether this is a wrapped error."""
|
156
|
+
return self._wrapped
|
157
|
+
|
158
|
+
@classmethod
|
159
|
+
def from_expanded_paths(
|
160
|
+
cls,
|
161
|
+
original_path: str,
|
162
|
+
expanded_path: str,
|
163
|
+
base_dir: str,
|
164
|
+
allowed_dirs: List[str],
|
165
|
+
error_logged: bool = False,
|
166
|
+
) -> "PathSecurityError":
|
167
|
+
"""Create error with expanded path information.
|
185
168
|
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
169
|
+
Args:
|
170
|
+
original_path: Original path provided
|
171
|
+
expanded_path: Expanded absolute path
|
172
|
+
base_dir: Base directory
|
173
|
+
allowed_dirs: List of allowed directories
|
174
|
+
error_logged: Whether error has been logged
|
175
|
+
|
176
|
+
Returns:
|
177
|
+
PathSecurityError instance
|
178
|
+
"""
|
179
|
+
context = {
|
180
|
+
"original_path": original_path,
|
181
|
+
"expanded_path": expanded_path,
|
182
|
+
"base_dir": base_dir,
|
183
|
+
"allowed_dirs": allowed_dirs,
|
184
|
+
"reason": SecurityErrorReasons.PATH_OUTSIDE_ALLOWED,
|
185
|
+
"details": "The path resolves to a location outside the allowed directories",
|
186
|
+
"troubleshooting": [
|
187
|
+
f"Ensure path is within base directory: {base_dir}",
|
188
|
+
"Use --allowed-dir to specify additional allowed directories",
|
189
|
+
f"Current allowed directories: {', '.join(allowed_dirs)}",
|
190
|
+
],
|
191
|
+
}
|
192
|
+
|
193
|
+
return cls(
|
194
|
+
f"Access denied: {original_path!r} resolves to {expanded_path!r} which is "
|
195
|
+
f"outside base directory {base_dir!r}",
|
196
|
+
path=original_path,
|
197
|
+
error_logged=error_logged,
|
198
|
+
context=context,
|
199
|
+
)
|
190
200
|
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
201
|
+
@classmethod
|
202
|
+
def wrap_error(cls, msg: str, original: Exception) -> "PathSecurityError":
|
203
|
+
"""Wrap an existing error with additional context.
|
204
|
+
|
205
|
+
Args:
|
206
|
+
msg: New error message
|
207
|
+
original: Original error to wrap
|
208
|
+
|
209
|
+
Returns:
|
210
|
+
New PathSecurityError instance
|
211
|
+
"""
|
212
|
+
context = {
|
213
|
+
"wrapped_error": type(original).__name__,
|
214
|
+
"original_message": str(original),
|
215
|
+
}
|
216
|
+
|
217
|
+
if hasattr(original, "context"):
|
218
|
+
context.update(original.context)
|
219
|
+
|
220
|
+
return cls(
|
221
|
+
f"{msg}: {str(original)}",
|
222
|
+
path=getattr(original, "path", None),
|
223
|
+
error_logged=getattr(original, "error_logged", False),
|
224
|
+
wrapped=True,
|
225
|
+
context=context,
|
226
|
+
)
|
196
227
|
|
197
228
|
|
198
229
|
class TaskTemplateError(CLIError):
|
@@ -210,7 +241,22 @@ class TaskTemplateSyntaxError(TaskTemplateError):
|
|
210
241
|
class TaskTemplateVariableError(TaskTemplateError):
|
211
242
|
"""Raised when a task template uses undefined variables."""
|
212
243
|
|
213
|
-
|
244
|
+
def __init__(
|
245
|
+
self,
|
246
|
+
message: str,
|
247
|
+
context: Optional[Dict[str, Any]] = None,
|
248
|
+
) -> None:
|
249
|
+
"""Initialize error.
|
250
|
+
|
251
|
+
Args:
|
252
|
+
message: Error message
|
253
|
+
context: Additional error context
|
254
|
+
"""
|
255
|
+
super().__init__(
|
256
|
+
message,
|
257
|
+
context=context,
|
258
|
+
exit_code=ExitCode.VALIDATION_ERROR,
|
259
|
+
)
|
214
260
|
|
215
261
|
|
216
262
|
class TemplateValidationError(TaskTemplateError):
|
@@ -232,33 +278,103 @@ class SchemaError(CLIError):
|
|
232
278
|
|
233
279
|
|
234
280
|
class SchemaFileError(CLIError):
|
235
|
-
"""
|
281
|
+
"""Error raised when schema file cannot be read."""
|
236
282
|
|
237
283
|
def __init__(
|
238
284
|
self,
|
239
285
|
message: str,
|
240
286
|
schema_path: Optional[str] = None,
|
241
287
|
context: Optional[Dict[str, Any]] = None,
|
242
|
-
):
|
288
|
+
) -> None:
|
289
|
+
"""Initialize schema file error.
|
290
|
+
|
291
|
+
Args:
|
292
|
+
message: Error message
|
293
|
+
schema_path: Path to schema file
|
294
|
+
context: Additional context for the error
|
295
|
+
"""
|
243
296
|
context = context or {}
|
244
|
-
if schema_path:
|
297
|
+
if schema_path and "source" not in context:
|
245
298
|
context["schema_path"] = schema_path
|
246
|
-
|
299
|
+
context["source"] = schema_path # Use new standard field
|
300
|
+
context.setdefault(
|
301
|
+
"details",
|
302
|
+
"The schema file could not be read or contains errors",
|
303
|
+
)
|
304
|
+
context.setdefault(
|
305
|
+
"troubleshooting",
|
306
|
+
[
|
307
|
+
"Verify the schema file exists",
|
308
|
+
"Check if the schema file contains valid JSON",
|
309
|
+
"Ensure the schema follows the correct format",
|
310
|
+
"Check file permissions",
|
311
|
+
],
|
312
|
+
)
|
313
|
+
|
314
|
+
super().__init__(
|
315
|
+
message,
|
316
|
+
context=context,
|
317
|
+
exit_code=ExitCode.SCHEMA_ERROR,
|
318
|
+
)
|
319
|
+
|
320
|
+
@property
|
321
|
+
def schema_path(self) -> Optional[str]:
|
322
|
+
"""Get the schema path."""
|
323
|
+
return self.context.get("schema_path")
|
247
324
|
|
248
325
|
|
249
326
|
class SchemaValidationError(CLIError):
|
250
|
-
"""
|
327
|
+
"""Error raised when a schema fails validation."""
|
251
328
|
|
252
329
|
def __init__(
|
253
330
|
self,
|
254
331
|
message: str,
|
255
|
-
schema_path: Optional[str] = None,
|
256
332
|
context: Optional[Dict[str, Any]] = None,
|
257
333
|
):
|
258
334
|
context = context or {}
|
259
|
-
|
260
|
-
|
261
|
-
|
335
|
+
|
336
|
+
# Format error message with tips
|
337
|
+
formatted_message = [message]
|
338
|
+
|
339
|
+
if "path" in context:
|
340
|
+
formatted_message.append(f"\nLocation: {context['path']}")
|
341
|
+
|
342
|
+
if "found" in context:
|
343
|
+
formatted_message.append(f"Found: {context['found']}")
|
344
|
+
|
345
|
+
if "count" in context:
|
346
|
+
formatted_message.append(f"Count: {context['count']}")
|
347
|
+
|
348
|
+
if "missing_required" in context:
|
349
|
+
formatted_message.append(
|
350
|
+
f"Missing required: {context['missing_required']}"
|
351
|
+
)
|
352
|
+
|
353
|
+
if "extra_required" in context:
|
354
|
+
formatted_message.append(
|
355
|
+
f"Extra required: {context['extra_required']}"
|
356
|
+
)
|
357
|
+
|
358
|
+
if "prohibited_used" in context:
|
359
|
+
formatted_message.append(
|
360
|
+
f"Prohibited keywords used: {context['prohibited_used']}"
|
361
|
+
)
|
362
|
+
|
363
|
+
if "tips" in context:
|
364
|
+
formatted_message.append("\nHow to fix:")
|
365
|
+
for tip in context["tips"]:
|
366
|
+
if isinstance(tip, dict):
|
367
|
+
# Format JSON example
|
368
|
+
formatted_message.append("Example schema:")
|
369
|
+
formatted_message.append(json.dumps(tip, indent=2))
|
370
|
+
else:
|
371
|
+
formatted_message.append(f"- {tip}")
|
372
|
+
|
373
|
+
super().__init__(
|
374
|
+
"\n".join(formatted_message),
|
375
|
+
context=context,
|
376
|
+
exit_code=ExitCode.SCHEMA_ERROR,
|
377
|
+
)
|
262
378
|
|
263
379
|
|
264
380
|
class ModelCreationError(CLIError):
|
@@ -307,12 +423,6 @@ class ModelNotSupportedError(CLIError):
|
|
307
423
|
pass
|
308
424
|
|
309
425
|
|
310
|
-
class ModelVersionError(CLIError):
|
311
|
-
"""Exception raised when a model version is not supported."""
|
312
|
-
|
313
|
-
pass
|
314
|
-
|
315
|
-
|
316
426
|
class StreamInterruptedError(CLIError):
|
317
427
|
"""Exception raised when a stream is interrupted."""
|
318
428
|
|
@@ -344,24 +454,54 @@ class EmptyResponseError(CLIError):
|
|
344
454
|
|
345
455
|
|
346
456
|
class InvalidResponseFormatError(CLIError):
|
347
|
-
"""
|
457
|
+
"""Raised when the response format is invalid."""
|
348
458
|
|
349
|
-
|
459
|
+
def __init__(self, message: str, context: Optional[Dict[str, Any]] = None):
|
460
|
+
if "schema must be a JSON Schema of 'type: \"object\"'" in message:
|
461
|
+
message = (
|
462
|
+
"The schema must have a root type of 'object', but got 'array'. "
|
463
|
+
"To fix this, wrap your array in an object. For example:\n\n"
|
464
|
+
"{\n"
|
465
|
+
' "type": "object",\n'
|
466
|
+
' "properties": {\n'
|
467
|
+
' "items": {\n'
|
468
|
+
' "type": "array",\n'
|
469
|
+
' "items": { ... your array items schema ... }\n'
|
470
|
+
" }\n"
|
471
|
+
" },\n"
|
472
|
+
' "required": ["items"]\n'
|
473
|
+
"}\n\n"
|
474
|
+
"Then update your template to handle the wrapper object."
|
475
|
+
)
|
476
|
+
super().__init__(
|
477
|
+
message,
|
478
|
+
exit_code=ExitCode.API_ERROR,
|
479
|
+
context=context,
|
480
|
+
)
|
350
481
|
|
351
482
|
|
352
483
|
class OpenAIClientError(CLIError):
|
353
|
-
"""Exception raised when there's an error with the OpenAI client.
|
484
|
+
"""Exception raised when there's an error with the OpenAI client.
|
354
485
|
|
355
|
-
|
486
|
+
This is a wrapper around openai_structured's OpenAIClientError to maintain
|
487
|
+
compatibility with our CLI error handling.
|
488
|
+
"""
|
489
|
+
|
490
|
+
def __init__(
|
491
|
+
self,
|
492
|
+
message: str,
|
493
|
+
exit_code: ExitCode = ExitCode.API_ERROR,
|
494
|
+
context: Optional[Dict[str, Any]] = None,
|
495
|
+
):
|
496
|
+
super().__init__(message, exit_code=exit_code, context=context)
|
356
497
|
|
357
498
|
|
358
499
|
# Export public API
|
359
500
|
__all__ = [
|
360
|
-
"CLIError",
|
361
501
|
"VariableError",
|
362
502
|
"PathError",
|
363
503
|
"PathSecurityError",
|
364
|
-
"
|
504
|
+
"OstructFileNotFoundError",
|
365
505
|
"FileReadError",
|
366
506
|
"DirectoryNotFoundError",
|
367
507
|
"SchemaValidationError",
|
@@ -369,7 +509,6 @@ __all__ = [
|
|
369
509
|
"InvalidJSONError",
|
370
510
|
"ModelCreationError",
|
371
511
|
"ModelNotSupportedError",
|
372
|
-
"ModelVersionError",
|
373
512
|
"StreamInterruptedError",
|
374
513
|
"StreamBufferError",
|
375
514
|
"StreamParseError",
|
@@ -0,0 +1,18 @@
|
|
1
|
+
"""Exit codes for CLI operations."""
|
2
|
+
|
3
|
+
from enum import IntEnum
|
4
|
+
|
5
|
+
|
6
|
+
class ExitCode(IntEnum):
|
7
|
+
"""Exit codes for CLI operations."""
|
8
|
+
|
9
|
+
SUCCESS = 0
|
10
|
+
INTERNAL_ERROR = 1
|
11
|
+
USAGE_ERROR = 2
|
12
|
+
DATA_ERROR = 3
|
13
|
+
VALIDATION_ERROR = 4
|
14
|
+
API_ERROR = 5
|
15
|
+
SCHEMA_ERROR = 6
|
16
|
+
UNKNOWN_ERROR = 7
|
17
|
+
SECURITY_ERROR = 8
|
18
|
+
FILE_ERROR = 9
|
ostruct/cli/file_info.py
CHANGED
@@ -3,9 +3,10 @@
|
|
3
3
|
import hashlib
|
4
4
|
import logging
|
5
5
|
import os
|
6
|
+
from pathlib import Path
|
6
7
|
from typing import Any, Optional
|
7
8
|
|
8
|
-
from .errors import
|
9
|
+
from .errors import FileReadError, OstructFileNotFoundError, PathSecurityError
|
9
10
|
from .security import SecurityManager
|
10
11
|
|
11
12
|
logger = logging.getLogger(__name__)
|
@@ -73,7 +74,7 @@ class FileInfo:
|
|
73
74
|
# Check if it's a regular file (not a directory, device, etc.)
|
74
75
|
if not resolved_path.is_file():
|
75
76
|
logger.debug("Not a regular file: %s", resolved_path)
|
76
|
-
raise
|
77
|
+
raise OstructFileNotFoundError(
|
77
78
|
f"Not a regular file: {os.path.basename(str(path))}"
|
78
79
|
)
|
79
80
|
|
@@ -98,10 +99,10 @@ class FileInfo:
|
|
98
99
|
)
|
99
100
|
raise
|
100
101
|
|
101
|
-
except
|
102
|
+
except OstructFileNotFoundError as e:
|
102
103
|
# Re-raise with standardized message format
|
103
104
|
logger.debug("File not found error: %s", e)
|
104
|
-
raise
|
105
|
+
raise OstructFileNotFoundError(
|
105
106
|
f"File not found: {os.path.basename(str(path))}"
|
106
107
|
) from e
|
107
108
|
|
@@ -126,16 +127,31 @@ class FileInfo:
|
|
126
127
|
|
127
128
|
@property
|
128
129
|
def path(self) -> str:
|
129
|
-
"""Get the relative
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
130
|
+
"""Get the path relative to security manager's base directory.
|
131
|
+
|
132
|
+
Returns a path relative to the security manager's base directory.
|
133
|
+
This ensures consistent path handling across the entire codebase.
|
134
|
+
|
135
|
+
Example:
|
136
|
+
security_manager = SecurityManager(base_dir="/base")
|
137
|
+
file_info = FileInfo("/base/file.txt", security_manager)
|
138
|
+
print(file_info.path) # Outputs: "file.txt"
|
139
|
+
|
140
|
+
Returns:
|
141
|
+
str: Path relative to security manager's base directory
|
142
|
+
|
143
|
+
Raises:
|
144
|
+
ValueError: If the path is not within the base directory
|
145
|
+
"""
|
146
|
+
try:
|
147
|
+
abs_path = Path(self.abs_path)
|
148
|
+
base_dir = Path(self.__security_manager.base_dir)
|
149
|
+
return str(abs_path.relative_to(base_dir))
|
150
|
+
except ValueError as e:
|
151
|
+
logger.error("Error making path relative: %s", e)
|
152
|
+
raise ValueError(
|
153
|
+
f"Path {abs_path} must be within base directory {base_dir}"
|
154
|
+
)
|
139
155
|
|
140
156
|
@path.setter
|
141
157
|
def path(self, value: str) -> None:
|
ostruct/cli/file_list.py
CHANGED
@@ -69,16 +69,13 @@ class FileInfoList(List[FileInfo]):
|
|
69
69
|
|
70
70
|
Returns:
|
71
71
|
Union[str, List[str]]: For a single file from file mapping, returns its content as a string.
|
72
|
-
For multiple files
|
73
|
-
|
74
|
-
Raises:
|
75
|
-
ValueError: If the list is empty
|
72
|
+
For multiple files, directory mapping, or empty list, returns a list of contents.
|
76
73
|
"""
|
77
74
|
# Take snapshot under lock
|
78
75
|
with self._lock:
|
79
76
|
if not self:
|
80
77
|
logger.debug("FileInfoList.content called but list is empty")
|
81
|
-
|
78
|
+
return []
|
82
79
|
|
83
80
|
# Make a copy of the files we need to access
|
84
81
|
if len(self) == 1 and not self._from_dir:
|
@@ -112,15 +109,12 @@ class FileInfoList(List[FileInfo]):
|
|
112
109
|
|
113
110
|
Returns:
|
114
111
|
Union[str, List[str]]: For a single file from file mapping, returns its path as a string.
|
115
|
-
For multiple files
|
116
|
-
|
117
|
-
Raises:
|
118
|
-
ValueError: If the list is empty
|
112
|
+
For multiple files, directory mapping, or empty list, returns a list of paths.
|
119
113
|
"""
|
120
114
|
# First get a snapshot of the list state under the lock
|
121
115
|
with self._lock:
|
122
116
|
if not self:
|
123
|
-
|
117
|
+
return []
|
124
118
|
if len(self) == 1 and not self._from_dir:
|
125
119
|
file_info = self[0]
|
126
120
|
is_single = True
|