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/errors.py CHANGED
@@ -1,13 +1,50 @@
1
1
  """Custom error classes for CLI error handling."""
2
2
 
3
- from pathlib import Path
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
- class CLIError(Exception):
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
- pass
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(VariableError):
68
+ class InvalidJSONError(CLIError):
32
69
  """Raised when JSON parsing fails for a variable value."""
33
70
 
34
- pass
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
- pass
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
- pass
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 DirectoryNotFoundError(PathError):
50
- """Raised when a specified directory does not exist."""
51
-
52
- pass
102
+ class FileReadError(PathError):
103
+ """Raised when a file cannot be read or decoded.
53
104
 
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
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, 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
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
- 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.
115
+ class DirectoryNotFoundError(PathError):
116
+ """Raised when a specified directory does not exist."""
96
117
 
97
- Args:
98
- path: Path that was denied
99
- reason: Optional reason for denial
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
- 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
+ class PathSecurityError(CLIError, SecurityPathSecurityError):
124
+ """CLI wrapper for security package's PathSecurityError.
123
125
 
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)
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
- @classmethod
138
- def traversal_attempt(
139
- cls, path: Path, error_logged: bool = False
140
- ) -> "PathSecurityError":
141
- """Create error for directory traversal attempt.
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
- path: Path that attempted traversal
145
- error_logged: Whether this error has already been logged
146
-
147
- Returns:
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
- return cls(
151
- f"Access denied: {path} - directory traversal not allowed",
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
- @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"
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 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.
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
- 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
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
- 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)
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
- @classmethod
227
- def wrap_error(
228
- cls, context: str, original: "PathSecurityError"
229
- ) -> "PathSecurityError":
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
- Args:
233
- context: Additional context to add to the error message
234
- original: The original PathSecurityError to wrap
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
- 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)
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(SchemaError):
234
+ class SchemaFileError(CLIError):
281
235
  """Raised when a schema file is invalid or inaccessible."""
282
236
 
283
- pass
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(SchemaError):
249
+ class SchemaValidationError(CLIError):
287
250
  """Raised when a schema fails validation."""
288
251
 
289
- pass
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(SchemaError):
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
+ ]