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/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, TextIO, cast
5
+ from typing import Any, Dict, List, Optional
5
6
 
6
- import click
7
-
8
- from .security.errors import PathSecurityError as SecurityPathSecurityError
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
- """Raised when JSON parsing fails for a variable value."""
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__(message, context)
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, message: str, path: str, context: Optional[Dict[str, Any]] = None
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
- # Use path directly as the message without prepending "Directory not found: "
120
- super().__init__(path, path, context)
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
- This class bridges the security package's error handling with the CLI's
127
- error handling system, providing both sets of functionality.
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
- """Initialize both parent classes properly.
117
+ wrapped: bool = False,
118
+ context: Optional[Dict[str, Any]] = None,
119
+ ) -> None:
120
+ """Initialize error.
138
121
 
139
122
  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
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
- # 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
- )
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
- 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)
144
+ super().__init__(message, context=context)
145
+ self._wrapped = wrapped
146
+ self._error_logged = error_logged
169
147
 
170
148
  @property
171
- def has_been_logged(self) -> bool:
149
+ def error_logged(self) -> bool:
172
150
  """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]
151
+ return self._error_logged
180
152
 
181
153
  @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
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
- @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
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
- 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
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
- pass
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
- """Raised when a schema file is invalid or inaccessible."""
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
- super().__init__(message, context)
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
- """Raised when a schema fails validation."""
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
- if schema_path:
260
- context["schema_path"] = schema_path
261
- super().__init__(message, context)
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
- """Exception raised when the API response format is invalid."""
457
+ """Raised when the response format is invalid."""
348
458
 
349
- pass
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
- pass
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
- "FileNotFoundError",
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 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