ostruct-cli 0.2.0__py3-none-any.whl → 0.4.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 +466 -604
- ostruct/cli/click_options.py +257 -0
- ostruct/cli/errors.py +234 -183
- ostruct/cli/file_info.py +154 -50
- ostruct/cli/file_list.py +189 -64
- ostruct/cli/file_utils.py +95 -67
- ostruct/cli/path_utils.py +58 -77
- ostruct/cli/security/__init__.py +32 -0
- ostruct/cli/security/allowed_checker.py +47 -0
- ostruct/cli/security/case_manager.py +75 -0
- ostruct/cli/security/errors.py +184 -0
- ostruct/cli/security/normalization.py +161 -0
- ostruct/cli/security/safe_joiner.py +211 -0
- ostruct/cli/security/security_manager.py +353 -0
- ostruct/cli/security/symlink_resolver.py +483 -0
- ostruct/cli/security/types.py +108 -0
- ostruct/cli/security/windows_paths.py +404 -0
- ostruct/cli/template_filters.py +8 -5
- ostruct/cli/template_io.py +4 -2
- {ostruct_cli-0.2.0.dist-info → ostruct_cli-0.4.0.dist-info}/METADATA +9 -6
- ostruct_cli-0.4.0.dist-info/RECORD +36 -0
- ostruct/cli/security.py +0 -323
- ostruct/cli/security_types.py +0 -49
- ostruct_cli-0.2.0.dist-info/RECORD +0 -27
- {ostruct_cli-0.2.0.dist-info → ostruct_cli-0.4.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.2.0.dist-info → ostruct_cli-0.4.0.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.2.0.dist-info → ostruct_cli-0.4.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/errors.py
CHANGED
@@ -1,13 +1,50 @@
|
|
1
1
|
"""Custom error classes for CLI error handling."""
|
2
2
|
|
3
|
-
|
4
|
-
from typing import List, Optional
|
3
|
+
import logging
|
4
|
+
from typing import Any, Dict, List, Optional, TextIO, cast
|
5
5
|
|
6
|
+
import click
|
6
7
|
|
7
|
-
|
8
|
+
from .security.errors import PathSecurityError as SecurityPathSecurityError
|
9
|
+
|
10
|
+
logger = logging.getLogger(__name__)
|
11
|
+
|
12
|
+
|
13
|
+
class CLIError(click.ClickException):
|
8
14
|
"""Base class for all CLI errors."""
|
9
15
|
|
10
|
-
|
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)
|
11
48
|
|
12
49
|
|
13
50
|
class VariableError(CLIError):
|
@@ -28,217 +65,134 @@ class VariableValueError(VariableError):
|
|
28
65
|
pass
|
29
66
|
|
30
67
|
|
31
|
-
class InvalidJSONError(
|
68
|
+
class InvalidJSONError(CLIError):
|
32
69
|
"""Raised when JSON parsing fails for a variable value."""
|
33
70
|
|
34
|
-
|
71
|
+
def __init__(
|
72
|
+
self,
|
73
|
+
message: str,
|
74
|
+
source: Optional[str] = None,
|
75
|
+
context: Optional[Dict[str, Any]] = None,
|
76
|
+
):
|
77
|
+
context = context or {}
|
78
|
+
if source:
|
79
|
+
context["source"] = source
|
80
|
+
super().__init__(message, context)
|
35
81
|
|
36
82
|
|
37
83
|
class PathError(CLIError):
|
38
84
|
"""Base class for path-related errors."""
|
39
85
|
|
40
|
-
|
86
|
+
def __init__(
|
87
|
+
self, message: str, path: str, context: Optional[Dict[str, Any]] = None
|
88
|
+
):
|
89
|
+
context = context or {}
|
90
|
+
context["path"] = path
|
91
|
+
super().__init__(message, context)
|
41
92
|
|
42
93
|
|
43
94
|
class FileNotFoundError(PathError):
|
44
95
|
"""Raised when a specified file does not exist."""
|
45
96
|
|
46
|
-
|
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)
|
47
100
|
|
48
101
|
|
49
|
-
class
|
50
|
-
"""Raised when a
|
51
|
-
|
52
|
-
pass
|
102
|
+
class FileReadError(PathError):
|
103
|
+
"""Raised when a file cannot be read or decoded.
|
53
104
|
|
54
|
-
|
55
|
-
|
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
|
105
|
+
This is a wrapper exception that preserves the original cause (FileNotFoundError,
|
106
|
+
UnicodeDecodeError, etc) while providing a consistent interface for error handling.
|
62
107
|
"""
|
63
108
|
|
64
109
|
def __init__(
|
65
|
-
self, message: str,
|
66
|
-
)
|
67
|
-
|
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
|
110
|
+
self, message: str, path: str, context: Optional[Dict[str, Any]] = None
|
111
|
+
):
|
112
|
+
super().__init__(message, path, context)
|
78
113
|
|
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
114
|
|
84
|
-
|
85
|
-
|
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.
|
115
|
+
class DirectoryNotFoundError(PathError):
|
116
|
+
"""Raised when a specified directory does not exist."""
|
96
117
|
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
error_logged: Whether this error has already been logged
|
118
|
+
def __init__(self, path: str, context: Optional[Dict[str, Any]] = None):
|
119
|
+
# Use path directly as the message without prepending "Directory not found: "
|
120
|
+
super().__init__(path, path, context)
|
101
121
|
|
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
122
|
|
119
|
-
|
120
|
-
|
121
|
-
base_dir: Optional base directory for context
|
122
|
-
error_logged: Whether this error has already been logged
|
123
|
+
class PathSecurityError(CLIError, SecurityPathSecurityError):
|
124
|
+
"""CLI wrapper for security package's PathSecurityError.
|
123
125
|
|
124
|
-
|
125
|
-
|
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)
|
126
|
+
This class bridges the security package's error handling with the CLI's
|
127
|
+
error handling system, providing both sets of functionality.
|
128
|
+
"""
|
136
129
|
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
130
|
+
def __init__(
|
131
|
+
self,
|
132
|
+
message: str,
|
133
|
+
path: Optional[str] = None,
|
134
|
+
context: Optional[Dict[str, Any]] = None,
|
135
|
+
error_logged: bool = False,
|
136
|
+
):
|
137
|
+
"""Initialize both parent classes properly.
|
142
138
|
|
143
139
|
Args:
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
PathSecurityError with standardized message
|
140
|
+
message: The error message
|
141
|
+
path: The path that caused the error
|
142
|
+
context: Additional context about the error
|
143
|
+
error_logged: Whether this error has been logged
|
149
144
|
"""
|
150
|
-
|
151
|
-
|
145
|
+
# Initialize security error first
|
146
|
+
SecurityPathSecurityError.__init__(
|
147
|
+
self,
|
148
|
+
message,
|
149
|
+
path=path or "",
|
150
|
+
context=context,
|
152
151
|
error_logged=error_logged,
|
153
152
|
)
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
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"
|
153
|
+
# Initialize CLI error with the same context
|
154
|
+
CLIError.__init__(self, message, context=self.context)
|
155
|
+
# Ensure error_logged state is consistent
|
156
|
+
self._has_been_logged = error_logged
|
157
|
+
logger.debug(
|
158
|
+
"Created CLI PathSecurityError with message=%r, path=%r, context=%r, error_logged=%r",
|
159
|
+
message,
|
160
|
+
path,
|
161
|
+
self.context,
|
162
|
+
error_logged,
|
186
163
|
)
|
187
|
-
return cls("\n".join(parts), error_logged=error_logged)
|
188
164
|
|
189
|
-
def
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
base_dir: Optional[str] = None,
|
194
|
-
allowed_dirs: Optional[List[str]] = None,
|
195
|
-
) -> str:
|
196
|
-
"""Format error message with additional context.
|
165
|
+
def show(self, file: Optional[TextIO] = None) -> None:
|
166
|
+
"""Show the error with CLI formatting."""
|
167
|
+
logger.debug("Showing error with context: %r", self.context)
|
168
|
+
super().show(file)
|
197
169
|
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
allowed_dirs: Optional list of allowed directories
|
170
|
+
@property
|
171
|
+
def has_been_logged(self) -> bool:
|
172
|
+
"""Whether this error has been logged."""
|
173
|
+
return self._has_been_logged or super().has_been_logged
|
203
174
|
|
204
|
-
|
205
|
-
|
206
|
-
"""
|
207
|
-
|
208
|
-
|
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)
|
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]
|
225
180
|
|
226
|
-
@
|
227
|
-
def
|
228
|
-
|
229
|
-
|
230
|
-
"""Wrap an error with additional context while preserving attributes.
|
181
|
+
@property
|
182
|
+
def error_logged(self) -> bool:
|
183
|
+
"""Whether this error has been logged (alias for has_been_logged)."""
|
184
|
+
return self.has_been_logged
|
231
185
|
|
232
|
-
|
233
|
-
|
234
|
-
|
186
|
+
@error_logged.setter
|
187
|
+
def error_logged(self, value: bool) -> None:
|
188
|
+
"""Set whether this error has been logged (alias for has_been_logged)."""
|
189
|
+
self.has_been_logged = value
|
235
190
|
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
return cls(message, error_logged=original.error_logged, wrapped=True)
|
191
|
+
def __str__(self) -> str:
|
192
|
+
"""Format the error message with context."""
|
193
|
+
msg = SecurityPathSecurityError.__str__(self)
|
194
|
+
logger.debug("Formatted error message: %r", msg)
|
195
|
+
return msg
|
242
196
|
|
243
197
|
|
244
198
|
class TaskTemplateError(CLIError):
|
@@ -277,19 +231,37 @@ class SchemaError(CLIError):
|
|
277
231
|
pass
|
278
232
|
|
279
233
|
|
280
|
-
class SchemaFileError(
|
234
|
+
class SchemaFileError(CLIError):
|
281
235
|
"""Raised when a schema file is invalid or inaccessible."""
|
282
236
|
|
283
|
-
|
237
|
+
def __init__(
|
238
|
+
self,
|
239
|
+
message: str,
|
240
|
+
schema_path: Optional[str] = None,
|
241
|
+
context: Optional[Dict[str, Any]] = None,
|
242
|
+
):
|
243
|
+
context = context or {}
|
244
|
+
if schema_path:
|
245
|
+
context["schema_path"] = schema_path
|
246
|
+
super().__init__(message, context)
|
284
247
|
|
285
248
|
|
286
|
-
class SchemaValidationError(
|
249
|
+
class SchemaValidationError(CLIError):
|
287
250
|
"""Raised when a schema fails validation."""
|
288
251
|
|
289
|
-
|
252
|
+
def __init__(
|
253
|
+
self,
|
254
|
+
message: str,
|
255
|
+
schema_path: Optional[str] = None,
|
256
|
+
context: Optional[Dict[str, Any]] = None,
|
257
|
+
):
|
258
|
+
context = context or {}
|
259
|
+
if schema_path:
|
260
|
+
context["schema_path"] = schema_path
|
261
|
+
super().__init__(message, context)
|
290
262
|
|
291
263
|
|
292
|
-
class ModelCreationError(
|
264
|
+
class ModelCreationError(CLIError):
|
293
265
|
"""Base class for model creation errors."""
|
294
266
|
|
295
267
|
pass
|
@@ -327,3 +299,82 @@ class ModelValidationError(ModelCreationError):
|
|
327
299
|
f"Model '{model_name}' validation failed:\n"
|
328
300
|
+ "\n".join(validation_errors)
|
329
301
|
)
|
302
|
+
|
303
|
+
|
304
|
+
class ModelNotSupportedError(CLIError):
|
305
|
+
"""Exception raised when a model doesn't support structured output."""
|
306
|
+
|
307
|
+
pass
|
308
|
+
|
309
|
+
|
310
|
+
class ModelVersionError(CLIError):
|
311
|
+
"""Exception raised when a model version is not supported."""
|
312
|
+
|
313
|
+
pass
|
314
|
+
|
315
|
+
|
316
|
+
class StreamInterruptedError(CLIError):
|
317
|
+
"""Exception raised when a stream is interrupted."""
|
318
|
+
|
319
|
+
pass
|
320
|
+
|
321
|
+
|
322
|
+
class StreamBufferError(CLIError):
|
323
|
+
"""Exception raised when there's an error with the stream buffer."""
|
324
|
+
|
325
|
+
pass
|
326
|
+
|
327
|
+
|
328
|
+
class StreamParseError(CLIError):
|
329
|
+
"""Exception raised when there's an error parsing the stream."""
|
330
|
+
|
331
|
+
pass
|
332
|
+
|
333
|
+
|
334
|
+
class APIResponseError(CLIError):
|
335
|
+
"""Exception raised when there's an error with the API response."""
|
336
|
+
|
337
|
+
pass
|
338
|
+
|
339
|
+
|
340
|
+
class EmptyResponseError(CLIError):
|
341
|
+
"""Exception raised when the API returns an empty response."""
|
342
|
+
|
343
|
+
pass
|
344
|
+
|
345
|
+
|
346
|
+
class InvalidResponseFormatError(CLIError):
|
347
|
+
"""Exception raised when the API response format is invalid."""
|
348
|
+
|
349
|
+
pass
|
350
|
+
|
351
|
+
|
352
|
+
class OpenAIClientError(CLIError):
|
353
|
+
"""Exception raised when there's an error with the OpenAI client."""
|
354
|
+
|
355
|
+
pass
|
356
|
+
|
357
|
+
|
358
|
+
# Export public API
|
359
|
+
__all__ = [
|
360
|
+
"CLIError",
|
361
|
+
"VariableError",
|
362
|
+
"PathError",
|
363
|
+
"PathSecurityError",
|
364
|
+
"FileNotFoundError",
|
365
|
+
"FileReadError",
|
366
|
+
"DirectoryNotFoundError",
|
367
|
+
"SchemaValidationError",
|
368
|
+
"SchemaFileError",
|
369
|
+
"InvalidJSONError",
|
370
|
+
"ModelCreationError",
|
371
|
+
"ModelNotSupportedError",
|
372
|
+
"ModelVersionError",
|
373
|
+
"StreamInterruptedError",
|
374
|
+
"StreamBufferError",
|
375
|
+
"StreamParseError",
|
376
|
+
"APIResponseError",
|
377
|
+
"EmptyResponseError",
|
378
|
+
"InvalidResponseFormatError",
|
379
|
+
"OpenAIClientError",
|
380
|
+
]
|