ostruct-cli 0.4.0__py3-none-any.whl → 0.5.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,52 +1,16 @@
1
1
  """Custom error classes for CLI error handling."""
2
2
 
3
3
  import logging
4
- from typing import Any, Dict, List, Optional, TextIO, cast
4
+ from typing import Any, Dict, List, Optional
5
5
 
6
- import click
7
-
8
- from .security.errors import PathSecurityError as SecurityPathSecurityError
6
+ from .base_errors import CLIError, OstructFileNotFoundError
7
+ from .exit_codes import ExitCode
8
+ from .security.base import SecurityErrorBase
9
+ from .security.errors import SecurityErrorReasons
9
10
 
10
11
  logger = logging.getLogger(__name__)
11
12
 
12
13
 
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
14
  class VariableError(CLIError):
51
15
  """Base class for variable-related errors."""
52
16
 
@@ -66,7 +30,7 @@ class VariableValueError(VariableError):
66
30
 
67
31
 
68
32
  class InvalidJSONError(CLIError):
69
- """Raised when JSON parsing fails for a variable value."""
33
+ """Error raised when JSON is invalid."""
70
34
 
71
35
  def __init__(
72
36
  self,
@@ -74,29 +38,36 @@ class InvalidJSONError(CLIError):
74
38
  source: Optional[str] = None,
75
39
  context: Optional[Dict[str, Any]] = None,
76
40
  ):
41
+ """Initialize invalid JSON error.
42
+
43
+ Args:
44
+ message: Error message
45
+ source: Source of invalid JSON
46
+ context: Additional context for the error
47
+ """
77
48
  context = context or {}
78
49
  if source:
79
50
  context["source"] = source
80
- super().__init__(message, context)
51
+ super().__init__(
52
+ message,
53
+ exit_code=ExitCode.DATA_ERROR,
54
+ context=context,
55
+ )
81
56
 
82
57
 
83
58
  class PathError(CLIError):
84
59
  """Base class for path-related errors."""
85
60
 
86
61
  def __init__(
87
- self, message: str, path: str, context: Optional[Dict[str, Any]] = None
62
+ self,
63
+ message: str,
64
+ path: str,
65
+ context: Optional[Dict[str, Any]] = None,
66
+ exit_code: int = ExitCode.FILE_ERROR,
88
67
  ):
89
68
  context = context or {}
90
69
  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)
70
+ super().__init__(message, context=context, exit_code=exit_code)
100
71
 
101
72
 
102
73
  class FileReadError(PathError):
@@ -116,83 +87,142 @@ class DirectoryNotFoundError(PathError):
116
87
  """Raised when a specified directory does not exist."""
117
88
 
118
89
  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)
121
-
90
+ context = context or {}
91
+ context.update(
92
+ {
93
+ "details": "The specified directory does not exist or cannot be accessed",
94
+ "troubleshooting": [
95
+ "Check if the directory exists",
96
+ "Verify the path spelling is correct",
97
+ "Check directory permissions",
98
+ "Ensure parent directories exist",
99
+ "Use --allowed-dir to specify additional allowed directories",
100
+ ],
101
+ }
102
+ )
103
+ super().__init__(
104
+ f"Directory not found: {path}", path=path, context=context
105
+ )
122
106
 
123
- class PathSecurityError(CLIError, SecurityPathSecurityError):
124
- """CLI wrapper for security package's PathSecurityError.
125
107
 
126
- This class bridges the security package's error handling with the CLI's
127
- error handling system, providing both sets of functionality.
128
- """
108
+ class PathSecurityError(SecurityErrorBase):
109
+ """Error raised when a path violates security constraints."""
129
110
 
130
111
  def __init__(
131
112
  self,
132
113
  message: str,
133
114
  path: Optional[str] = None,
134
- context: Optional[Dict[str, Any]] = None,
135
115
  error_logged: bool = False,
136
- ):
137
- """Initialize both parent classes properly.
116
+ wrapped: bool = False,
117
+ context: Optional[Dict[str, Any]] = None,
118
+ ) -> None:
119
+ """Initialize error.
138
120
 
139
121
  Args:
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
122
+ message: Error message
123
+ path: Path that caused the error
124
+ error_logged: Whether error has been logged
125
+ wrapped: Whether this is a wrapped error
126
+ context: Additional error context
144
127
  """
145
- # Initialize security error first
146
- SecurityPathSecurityError.__init__(
147
- self,
148
- message,
149
- path=path or "",
150
- context=context,
151
- error_logged=error_logged,
152
- )
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,
163
- )
128
+ context = context or {}
129
+ if path is not None:
130
+ context["path"] = path
131
+ context.setdefault(
132
+ "details", "The specified path violates security constraints"
133
+ )
134
+ context.setdefault(
135
+ "troubleshooting",
136
+ [
137
+ "Check if the path is within allowed directories",
138
+ "Use --allowed-dir to specify additional allowed directories",
139
+ "Verify path permissions",
140
+ ],
141
+ )
164
142
 
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)
143
+ super().__init__(message, context=context)
144
+ self._wrapped = wrapped
145
+ self._error_logged = error_logged
169
146
 
170
147
  @property
171
- def has_been_logged(self) -> bool:
148
+ def error_logged(self) -> bool:
172
149
  """Whether this error has been logged."""
173
- return self._has_been_logged or super().has_been_logged
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]
150
+ return self._error_logged
180
151
 
181
152
  @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
153
+ def wrapped(self) -> bool:
154
+ """Whether this is a wrapped error."""
155
+ return self._wrapped
156
+
157
+ @classmethod
158
+ def from_expanded_paths(
159
+ cls,
160
+ original_path: str,
161
+ expanded_path: str,
162
+ base_dir: str,
163
+ allowed_dirs: List[str],
164
+ error_logged: bool = False,
165
+ ) -> "PathSecurityError":
166
+ """Create error with expanded path information.
185
167
 
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
168
+ Args:
169
+ original_path: Original path provided
170
+ expanded_path: Expanded absolute path
171
+ base_dir: Base directory
172
+ allowed_dirs: List of allowed directories
173
+ error_logged: Whether error has been logged
174
+
175
+ Returns:
176
+ PathSecurityError instance
177
+ """
178
+ context = {
179
+ "original_path": original_path,
180
+ "expanded_path": expanded_path,
181
+ "base_dir": base_dir,
182
+ "allowed_dirs": allowed_dirs,
183
+ "reason": SecurityErrorReasons.PATH_OUTSIDE_ALLOWED,
184
+ "details": "The path resolves to a location outside the allowed directories",
185
+ "troubleshooting": [
186
+ f"Ensure path is within base directory: {base_dir}",
187
+ "Use --allowed-dir to specify additional allowed directories",
188
+ f"Current allowed directories: {', '.join(allowed_dirs)}",
189
+ ],
190
+ }
191
+
192
+ return cls(
193
+ f"Access denied: {original_path!r} resolves to {expanded_path!r} which is "
194
+ f"outside base directory {base_dir!r}",
195
+ path=original_path,
196
+ error_logged=error_logged,
197
+ context=context,
198
+ )
190
199
 
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
200
+ @classmethod
201
+ def wrap_error(cls, msg: str, original: Exception) -> "PathSecurityError":
202
+ """Wrap an existing error with additional context.
203
+
204
+ Args:
205
+ msg: New error message
206
+ original: Original error to wrap
207
+
208
+ Returns:
209
+ New PathSecurityError instance
210
+ """
211
+ context = {
212
+ "wrapped_error": type(original).__name__,
213
+ "original_message": str(original),
214
+ }
215
+
216
+ if hasattr(original, "context"):
217
+ context.update(original.context)
218
+
219
+ return cls(
220
+ f"{msg}: {str(original)}",
221
+ path=getattr(original, "path", None),
222
+ error_logged=getattr(original, "error_logged", False),
223
+ wrapped=True,
224
+ context=context,
225
+ )
196
226
 
197
227
 
198
228
  class TaskTemplateError(CLIError):
@@ -210,7 +240,22 @@ class TaskTemplateSyntaxError(TaskTemplateError):
210
240
  class TaskTemplateVariableError(TaskTemplateError):
211
241
  """Raised when a task template uses undefined variables."""
212
242
 
213
- pass
243
+ def __init__(
244
+ self,
245
+ message: str,
246
+ context: Optional[Dict[str, Any]] = None,
247
+ ) -> None:
248
+ """Initialize error.
249
+
250
+ Args:
251
+ message: Error message
252
+ context: Additional error context
253
+ """
254
+ super().__init__(
255
+ message,
256
+ context=context,
257
+ exit_code=ExitCode.VALIDATION_ERROR,
258
+ )
214
259
 
215
260
 
216
261
  class TemplateValidationError(TaskTemplateError):
@@ -232,18 +277,49 @@ class SchemaError(CLIError):
232
277
 
233
278
 
234
279
  class SchemaFileError(CLIError):
235
- """Raised when a schema file is invalid or inaccessible."""
280
+ """Error raised when schema file cannot be read."""
236
281
 
237
282
  def __init__(
238
283
  self,
239
284
  message: str,
240
285
  schema_path: Optional[str] = None,
241
286
  context: Optional[Dict[str, Any]] = None,
242
- ):
287
+ ) -> None:
288
+ """Initialize schema file error.
289
+
290
+ Args:
291
+ message: Error message
292
+ schema_path: Path to schema file
293
+ context: Additional context for the error
294
+ """
243
295
  context = context or {}
244
- if schema_path:
296
+ if schema_path and "source" not in context:
245
297
  context["schema_path"] = schema_path
246
- super().__init__(message, context)
298
+ context["source"] = schema_path # Use new standard field
299
+ context.setdefault(
300
+ "details",
301
+ "The schema file could not be read or contains errors",
302
+ )
303
+ context.setdefault(
304
+ "troubleshooting",
305
+ [
306
+ "Verify the schema file exists",
307
+ "Check if the schema file contains valid JSON",
308
+ "Ensure the schema follows the correct format",
309
+ "Check file permissions",
310
+ ],
311
+ )
312
+
313
+ super().__init__(
314
+ message,
315
+ context=context,
316
+ exit_code=ExitCode.SCHEMA_ERROR,
317
+ )
318
+
319
+ @property
320
+ def schema_path(self) -> Optional[str]:
321
+ """Get the schema path."""
322
+ return self.context.get("schema_path")
247
323
 
248
324
 
249
325
  class SchemaValidationError(CLIError):
@@ -258,7 +334,23 @@ class SchemaValidationError(CLIError):
258
334
  context = context or {}
259
335
  if schema_path:
260
336
  context["schema_path"] = schema_path
261
- super().__init__(message, context)
337
+ context["source"] = schema_path
338
+ context.setdefault("details", "The schema validation failed")
339
+ context.setdefault(
340
+ "troubleshooting",
341
+ [
342
+ "Check if the schema follows JSON Schema specification",
343
+ "Verify all required fields are present",
344
+ "Ensure field types are correctly specified",
345
+ "Check for any syntax errors in the schema",
346
+ ],
347
+ )
348
+
349
+ super().__init__(
350
+ message,
351
+ context=context,
352
+ exit_code=ExitCode.SCHEMA_ERROR,
353
+ )
262
354
 
263
355
 
264
356
  class ModelCreationError(CLIError):
@@ -307,12 +399,6 @@ class ModelNotSupportedError(CLIError):
307
399
  pass
308
400
 
309
401
 
310
- class ModelVersionError(CLIError):
311
- """Exception raised when a model version is not supported."""
312
-
313
- pass
314
-
315
-
316
402
  class StreamInterruptedError(CLIError):
317
403
  """Exception raised when a stream is interrupted."""
318
404
 
@@ -350,18 +436,27 @@ class InvalidResponseFormatError(CLIError):
350
436
 
351
437
 
352
438
  class OpenAIClientError(CLIError):
353
- """Exception raised when there's an error with the OpenAI client."""
439
+ """Exception raised when there's an error with the OpenAI client.
354
440
 
355
- pass
441
+ This is a wrapper around openai_structured's OpenAIClientError to maintain
442
+ compatibility with our CLI error handling.
443
+ """
444
+
445
+ def __init__(
446
+ self,
447
+ message: str,
448
+ exit_code: ExitCode = ExitCode.API_ERROR,
449
+ context: Optional[Dict[str, Any]] = None,
450
+ ):
451
+ super().__init__(message, exit_code=exit_code, context=context)
356
452
 
357
453
 
358
454
  # Export public API
359
455
  __all__ = [
360
- "CLIError",
361
456
  "VariableError",
362
457
  "PathError",
363
458
  "PathSecurityError",
364
- "FileNotFoundError",
459
+ "OstructFileNotFoundError",
365
460
  "FileReadError",
366
461
  "DirectoryNotFoundError",
367
462
  "SchemaValidationError",
@@ -369,7 +464,6 @@ __all__ = [
369
464
  "InvalidJSONError",
370
465
  "ModelCreationError",
371
466
  "ModelNotSupportedError",
372
- "ModelVersionError",
373
467
  "StreamInterruptedError",
374
468
  "StreamBufferError",
375
469
  "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 FileNotFoundError, FileReadError, PathSecurityError
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 FileNotFoundError(
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 FileNotFoundError as e:
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 FileNotFoundError(
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 path of the file."""
130
- # If original path was relative, keep it relative
131
- if not os.path.isabs(self.__path):
132
- try:
133
- base_dir = self.__security_manager.base_dir
134
- abs_path = self.abs_path
135
- return os.path.relpath(abs_path, base_dir)
136
- except ValueError:
137
- pass
138
- return self.__path
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 or directory mapping, returns a list of contents.
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
- raise ValueError("No files in FileInfoList")
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 or directory mapping, returns a list of paths.
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
- raise ValueError("No files in FileInfoList")
117
+ return []
124
118
  if len(self) == 1 and not self._from_dir:
125
119
  file_info = self[0]
126
120
  is_single = True